Member List

Team members 4

Invite your colleagues and choose what they can access.

  • Anna Smith You
    Owners can’t remove themselves
  • Ben King
  • Lea Moreau
  • jon.diaz@example.com

@use 'sprucecss/scss/spruce' as *;

.member-list {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  padding: spacer('m');
  @include layout-stack('m');

  &__header {
    @include layout-stack('xxs');
  }

  &__title {
    align-items: center;
    display: flex;
    font-size: font-size('h5');
    gap: spacer('xs');
    margin: 0;
  }

  &__count {
    background-color: color('surface-muted');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('pill');
    color: color('muted');
    font-family: config('font-family-base', $typography);
    font-size: 0.75rem;
    line-height: 1;
    padding: 0.25em 0.6em;
  }

  &__description {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  &__invite {
    align-items: end;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
  }

  &__invite-email,
  &__invite-role {
    @include layout-stack('xxs');
  }

  &__invite-email {
    flex: 1 1 14rem;
  }

  &__invite-role {
    flex: 0 1 9rem;
  }

  &__items {
    border-block-start: config('border-width-base', $display) solid color('border');
    // The parent's layout-stack sets the margins.
    list-style: none;
    padding: 0;
  }

  &__item {
    align-items: center;
    border-block-end: config('border-width-base', $display) solid color('border');
    display: grid;
    gap: spacer('xs') spacer('s');
    grid-template-areas:
      'person person'
      'role actions';
    grid-template-columns: minmax(0, 9rem) 1fr;
    margin: 0 !important;
    padding-block: spacer('s');

    @include breakpoint('sm') {
      grid-template-areas: 'person role actions';
      grid-template-columns: minmax(0, 1fr) 8rem 10rem;
    }

    &:last-child {
      border-block-end: 0;
      padding-block-end: 0;
    }
  }

  &__person {
    align-items: center;
    display: flex;
    gap: spacer('s');
    grid-area: person;
    min-inline-size: 0;
  }

  &__avatar {
    align-items: center;
    aspect-ratio: 1;
    background-color: color-alpha('primary', 0.15);
    border-radius: radius('pill');
    color: color('primary');
    display: inline-flex;
    flex-shrink: 0;
    font-size: config('font-size-sm', $typography);
    font-weight: 700;
    inline-size: 2.5rem;
    justify-content: center;

    svg {
      block-size: 1.125rem;
      inline-size: 1.125rem;
    }

    &--pending {
      background-color: transparent;
      border: 1px dashed color('border-strong');
      color: color('muted');
    }
  }

  &__info {
    display: grid;
    line-height: config('line-height-md', $typography);
    min-inline-size: 0;
  }

  &__name {
    align-items: center;
    color: color('heading');
    display: flex;
    font-weight: 700;
    gap: spacer('xs');
    @include word-wrap;
  }

  &__badge {
    background-color: color-alpha('primary', 0.12);
    border-radius: radius('pill');
    color: color('primary');
    font-size: 0.6875rem;
    padding: 0.15em 0.6em;
  }

  &__email {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    @include text-ellipsis;
  }

  &__role {
    grid-area: role;
  }

  &__actions {
    display: flex;
    gap: spacer('xxs');
    grid-area: actions;
    justify-content: flex-end;
  }

  &__note {
    color: color('muted');
    font-size: 0.75rem;
    hyphens: manual;
    text-align: end;
  }

  &__item--pending &__name {
    font-weight: 500;
  }
}
<section class="member-list" aria-labelledby="member-list-title" data-member-list>
    <header class="member-list__header">
        <h3 class="member-list__title" id="member-list-title">Team members <span class="member-list__count" data-member-count>4</span></h3>
        <p class="member-list__description">Invite your colleagues and choose what they can access.</p>
    </header>
    <form class="member-list__invite" action="#" method="post" data-member-invite>
        <div class="member-list__invite-email">
            <label class="form-label" for="member-invite-email">Email address</label>
            <input class="form-control" id="member-invite-email" name="email" type="email" placeholder="name@company.com" autocomplete="off" required>
        </div>
        <div class="member-list__invite-role">
            <label class="form-label" for="member-invite-role">Role</label>
            <select class="form-control" id="member-invite-role" name="role">
                <option value="admin">Admin</option>
                <option value="editor" selected>Editor</option>
                <option value="viewer">Viewer</option>
            </select>
        </div>
        <button class="btn btn--primary" type="submit">Send invite</button>
    </form>
    <ul class="member-list__items" role="list" data-member-items>
        <li class="member-list__item" data-member>
            <div class="member-list__person">
                <span class="member-list__avatar" aria-hidden="true">AS</span>
                <span class="member-list__info">
                    <span class="member-list__name">Anna Smith <span class="member-list__badge">You</span></span>
                    <span class="member-list__email">anna@acme.com</span>
                </span>
            </div>
            <div class="member-list__role">
                <label class="sr-only" for="member-role-anna">Role of Anna Smith</label>
                <select class="form-control form-control--sm" id="member-role-anna" name="role" disabled><option value="owner" selected>Owner</option><option value="admin">Admin</option><option value="editor">Editor</option><option value="viewer">Viewer</option></select>
            </div>
            <div class="member-list__actions">
                <span class="member-list__note">Owners can’t remove themselves</span>
            </div>
        </li>
        <li class="member-list__item" data-member>
            <div class="member-list__person">
                <span class="member-list__avatar" aria-hidden="true">BK</span>
                <span class="member-list__info">
                    <span class="member-list__name">Ben King</span>
                    <span class="member-list__email">ben@acme.com</span>
                </span>
            </div>
            <div class="member-list__role">
                <label class="sr-only" for="member-role-ben">Role of Ben King</label>
                <select class="form-control form-control--sm" id="member-role-ben" name="role"><option value="owner">Owner</option><option value="admin" selected>Admin</option><option value="editor">Editor</option><option value="viewer">Viewer</option></select>
            </div>
            <div class="member-list__actions">
                <button class="btn btn--ghost-danger btn--sm" type="button" data-member-remove>Remove<span class="sr-only"> Ben King</span></button>
            </div>
        </li>
        <li class="member-list__item" data-member>
            <div class="member-list__person">
                <span class="member-list__avatar" aria-hidden="true">LM</span>
                <span class="member-list__info">
                    <span class="member-list__name">Lea Moreau</span>
                    <span class="member-list__email">lea@acme.com</span>
                </span>
            </div>
            <div class="member-list__role">
                <label class="sr-only" for="member-role-lea">Role of Lea Moreau</label>
                <select class="form-control form-control--sm" id="member-role-lea" name="role"><option value="owner">Owner</option><option value="admin">Admin</option><option value="editor" selected>Editor</option><option value="viewer">Viewer</option></select>
            </div>
            <div class="member-list__actions">
                <button class="btn btn--ghost-danger btn--sm" type="button" data-member-remove>Remove<span class="sr-only"> Lea Moreau</span></button>
            </div>
        </li>
        <li class="member-list__item member-list__item--pending" data-member>
            <div class="member-list__person">
                <span class="member-list__avatar member-list__avatar--pending" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
                <span class="member-list__info">
                    <span class="member-list__name">jon.diaz@example.com</span>
                    <span class="member-list__email">Invitation sent <time datetime="2026-10-02">2 days ago</time></span>
                </span>
            </div>
            <div class="member-list__role">
                <label class="sr-only" for="member-role-jon-diaz">Role of jon.diaz@example.com</label>
                <select class="form-control form-control--sm" id="member-role-jon-diaz" name="role"><option value="owner">Owner</option><option value="admin">Admin</option><option value="editor">Editor</option><option value="viewer" selected>Viewer</option></select>
            </div>
            <div class="member-list__actions">
                <button class="btn btn--ghost-secondary btn--sm" type="button">Resend<span class="sr-only"> the invitation to jon.diaz@example.com</span></button>
                <button class="btn btn--ghost-danger btn--sm" type="button" data-member-remove>Revoke<span class="sr-only"> the invitation of jon.diaz@example.com</span></button>
            </div>
        </li>
    </ul>
    <template data-member-template>
        <li class="member-list__item member-list__item--pending" data-member>
            <div class="member-list__person">
                <span class="member-list__avatar member-list__avatar--pending" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg></span>
                <span class="member-list__info">
                    <span class="member-list__name" data-member-email></span>
                    <span class="member-list__email">Invitation sent just now</span>
                </span>
            </div>
            <div class="member-list__role">
                <label class="sr-only" data-member-role-label>Role</label>
                <select class="form-control form-control--sm" name="role" data-member-role><option value="admin">Admin</option><option value="editor">Editor</option><option value="viewer">Viewer</option></select>
            </div>
            <div class="member-list__actions">
                <button class="btn btn--ghost-danger btn--sm" type="button" data-member-remove>Revoke<span class="sr-only" data-member-remove-label></span></button>
            </div>
        </li>
    </template>
    <p class="sr-only" aria-live="polite" data-member-live></p>
</section>
(() => {
  document.querySelectorAll('[data-member-list]').forEach((component) => {
    const form = component.querySelector('[data-member-invite]');
    const list = component.querySelector('[data-member-items]');
    const template = component.querySelector('[data-member-template]');
    const count = component.querySelector('[data-member-count]');
    const live = component.querySelector('[data-member-live]');
    let uid = 0;

    const updateCount = () => { count.textContent = list.children.length; };

    form.addEventListener('submit', (event) => {
      event.preventDefault();

      const email = form.elements.email.value.trim();
      const role = form.elements.role.value;
      const item = template.content.firstElementChild.cloneNode(true);
      const select = item.querySelector('[data-member-role]');
      const id = `member-role-new-${++uid}`;

      item.querySelector('[data-member-email]').textContent = email;
      item.querySelector('[data-member-role-label]').textContent = `Role of ${email}`;
      item.querySelector('[data-member-role-label]').htmlFor = id;
      item.querySelector('[data-member-remove-label]').textContent = ` the invitation of ${email}`;
      select.id = id;
      select.value = role;
      list.append(item);

      updateCount();
      live.textContent = `Invitation sent to ${email} as ${select.selectedOptions[0].text}.`;
      component.dispatchEvent(new CustomEvent('member:invite', { detail: { email, role } }));
      form.reset();
      form.elements.email.focus();
    });

    list.addEventListener('click', (event) => {
      const button = event.target.closest('[data-member-remove]');

      if (!button) {
        return;
      }

      const item = button.closest('[data-member]');
      const name = item.querySelector('.member-list__name').firstChild.textContent.trim();
      // Keep the focus in the list: move it to the next (or previous) row, or to the invite field.
      const next = item.nextElementSibling || item.previousElementSibling;

      item.remove();
      updateCount();
      live.textContent = `${name} removed.`;
      component.dispatchEvent(new CustomEvent('member:remove', { detail: { name } }));
      (next?.querySelector('select:not([disabled]), button') || form.elements.email).focus();
    });
  });
})();

Member List

Team management: an invite form, the members with their roles, and the pending invitations with resend and revoke actions.

Anatomy

  • .member-list__invite: the email and role fields with the submit button; they wrap on narrow screens.
  • .member-list__item: the __person (avatar, name, email or invitation status), the __role select and the __actions. The --pending modifier marks an invitation.
  • [data-member-template]: a template element with the markup of a new invitation, used by the script.

Accessibility and Usability

  • Every role select has a specific, visually hidden label ("Role of Ben King"); the action buttons have hidden context too ("Remove Ben King"), so a screen reader's list of controls makes sense.
  • The current user's role can't be changed (disabled) and they can't remove themselves; a note says why instead of a missing button.
  • New invitations and removals are announced in a polite live region. After a removal, the focus moves to the next row (or the invite field), so it doesn't get lost.
  • The invite email field has type="email" and required, so the browser validates it.

Technical Details

  • The script adds the new invitation from the template and dispatches member:invite (email, role) and member:remove (name) events: send the requests to your back-end in their listeners. Without JavaScript, the invite form submits normally.
  • Save role changes on the change event of the selects.
  • The row is a grid with named areas: one line from the sm breakpoint, two lines (person; role and actions) below it.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.