Team Invite

Invite your team

Invitations expire after 7 days. You have 4 free seats.

Pending invitations

  • maria@acme.com Admin · Sent 2 days ago
  • jonas@acme.com Member · Sent 5 days ago

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

.team-invite {
  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 {
    font-size: font-size('h5');
  }

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

    strong {
      color: color('heading');
    }
  }

  &__form {
    align-items: end;
    display: grid;
    gap: spacer('s');

    @include breakpoint('sm') {
      grid-template-columns: minmax(0, 1fr) 9rem auto;
    }
  }

  &__error {
    color: color('invalid', 'form');
    grid-column: 1 / -1;
    margin: 0;

    &[hidden] {
      display: none;
    }
  }

  &__pending {
    border-block-start: config('border-width-base', $display) solid color('border');
    padding-block-start: spacer('m');
    @include layout-stack('s');
  }

  &__subtitle {
    font-size: config('font-size-base', $typography);
  }

  &__list {
    list-style: none;
    padding: 0;
    @include layout-stack('xs');
  }

  &__item {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs') spacer('s');
  }

  &__initial {
    align-items: center;
    background-color: color-alpha('primary', 0.12);
    block-size: 2.25rem;
    border-radius: radius('pill');
    color: color('primary');
    display: flex;
    flex-shrink: 0;
    font-weight: 700;
    inline-size: 2.25rem;
    justify-content: center;
    text-transform: uppercase;
  }

  &__person {
    display: flex;
    flex: 1 1 12rem;
    flex-direction: column;
    line-height: config('line-height-md', $typography);
    min-inline-size: 0;
  }

  &__address {
    color: color('heading');
    font-weight: 600;
    overflow-wrap: anywhere;
  }

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

  &__actions {
    display: flex;
    gap: spacer('xxs');
  }

  &__empty {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }
}
<section class="team-invite" aria-labelledby="team-invite-title" data-team-invite>
    <header class="team-invite__header">
        <h2 class="team-invite__title" id="team-invite-title">Invite your team</h2>
        <p class="team-invite__text">Invitations expire after 7 days. You have <strong data-team-invite-seats>4</strong> free seats.</p>
    </header>
    <form class="team-invite__form" action="#" method="post" novalidate data-team-invite-form>
        <div class="form-group team-invite__email">
            <label class="form-label" for="team-invite-email">Email address</label>
            <input class="form-control" id="team-invite-email" name="email" type="email" autocomplete="off" placeholder="name@company.com" required aria-describedby="team-invite-error">
        </div>
        <div class="form-group team-invite__role">
            <label class="form-label" for="team-invite-role">Role</label>
            <select class="form-control" id="team-invite-role" name="role">
                <option>Member</option>
                <option>Admin</option>
                <option>Viewer</option>
            </select>
        </div>
        <button class="btn btn--primary" type="submit">Send invite</button>
        <p class="form-description team-invite__error" id="team-invite-error" hidden data-team-invite-error></p>
    </form>
    <div class="team-invite__pending">
        <h3 class="team-invite__subtitle">Pending invitations</h3>
        <ul class="team-invite__list" role="list" data-team-invite-list>
            <li class="team-invite__item" data-team-invite-item>
                <span class="team-invite__initial" aria-hidden="true">M</span>
                <span class="team-invite__person"><span class="team-invite__address">maria@acme.com</span> <span class="team-invite__meta">Admin · Sent 2 days ago</span></span>
                <span class="team-invite__actions">
                    <button class="btn btn--ghost-primary btn--sm" type="button" data-team-invite-resend>Resend<span class="sr-only"> to maria@acme.com</span></button>
                    <button class="btn btn--ghost-danger btn--sm" type="button" data-team-invite-cancel>Cancel<span class="sr-only"> the invitation of maria@acme.com</span></button>
                </span>
            </li>
            <li class="team-invite__item" data-team-invite-item>
                <span class="team-invite__initial" aria-hidden="true">J</span>
                <span class="team-invite__person"><span class="team-invite__address">jonas@acme.com</span> <span class="team-invite__meta">Member · Sent 5 days ago</span></span>
                <span class="team-invite__actions">
                    <button class="btn btn--ghost-primary btn--sm" type="button" data-team-invite-resend>Resend<span class="sr-only"> to jonas@acme.com</span></button>
                    <button class="btn btn--ghost-danger btn--sm" type="button" data-team-invite-cancel>Cancel<span class="sr-only"> the invitation of jonas@acme.com</span></button>
                </span>
            </li>
        </ul>
        <p class="team-invite__empty" hidden data-team-invite-empty>No pending invitations.</p>
    </div>
    <p class="sr-only" aria-live="polite" data-team-invite-status></p>
</section>
(() => {
  document.querySelectorAll('[data-team-invite]').forEach((section) => {
    const form = section.querySelector('[data-team-invite-form]');
    const email = form.elements.email;
    const role = form.elements.role;
    const error = section.querySelector('[data-team-invite-error]');
    const list = section.querySelector('[data-team-invite-list]');
    const empty = section.querySelector('[data-team-invite-empty]');
    const seats = section.querySelector('[data-team-invite-seats]');
    const status = section.querySelector('[data-team-invite-status]');
    const template = list.querySelector('[data-team-invite-item]').cloneNode(true);

    const addresses = () => [...list.querySelectorAll('.team-invite__address')].map((item) => item.textContent.toLowerCase());

    const update = () => {
      empty.hidden = list.children.length > 0;
    };

    const setError = (message) => {
      error.textContent = message;
      error.hidden = !message;
      email.setAttribute('aria-invalid', String(Boolean(message)));
    };

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

      const value = email.value.trim();

      if (!email.checkValidity() || !value) {
        setError('Enter an email address, like name@company.com.');
      } else if (addresses().includes(value.toLowerCase())) {
        setError(`${value} already has a pending invitation.`);
      } else if (Number(seats.textContent) === 0) {
        setError('There are no free seats left. Add seats to your plan first.');
      } else {
        const item = template.cloneNode(true);

        item.querySelector('.team-invite__initial').textContent = value.charAt(0);
        item.querySelector('.team-invite__address').textContent = value;
        item.querySelector('.team-invite__meta').textContent = `${role.value} · Sent just now`;
        item.querySelector('[data-team-invite-resend] .sr-only').textContent = ` to ${value}`;
        item.querySelector('[data-team-invite-cancel] .sr-only').textContent = ` the invitation of ${value}`;
        list.prepend(item);
        seats.textContent = Number(seats.textContent) - 1;
        status.textContent = `Invitation sent to ${value}.`;
        setError('');
        form.reset();
        update();
      }

      if (!error.hidden) email.focus();
    });

    list.addEventListener('click', (event) => {
      const item = event.target.closest('[data-team-invite-item]');
      const address = item?.querySelector('.team-invite__address').textContent;

      if (event.target.closest('[data-team-invite-resend]')) {
        item.querySelector('.team-invite__meta').textContent = item.querySelector('.team-invite__meta').textContent.replace(/Sent .*/, 'Sent just now');
        status.textContent = `Invitation resent to ${address}.`;
      }

      if (event.target.closest('[data-team-invite-cancel]')) {
        item.remove();
        seats.textContent = Number(seats.textContent) + 1;
        status.textContent = `The invitation of ${address} is canceled.`;
        update();
        email.focus();
      }
    });

    update();
  });
})();

Team Invite

Invite people to a workspace by email with a role, and manage the pending invitations (resend or cancel).

Anatomy

  • .team-invite__form: the email field, the role select and the submit button in a row from the sm breakpoint, with an __error message below.
  • .team-invite__pending: the list of pending invitations; every __item has an __initial, the address and meta, and the resend/cancel buttons.

Accessibility and Usability

  • The form validates on submit, not while typing: the error is linked to the field with aria-describedby, the field gets aria-invalid="true", and the focus returns to it.
  • The errors say how to fix the problem ("Enter an email address, like name@company.com"), and duplicate invitations and a full plan are caught before sending.
  • The resend and cancel buttons have hidden text with the address, so a list of "Cancel" buttons stays unambiguous.
  • Sending, resending and canceling are confirmed in a polite live region. After canceling, the focus moves to the email field, because the button is gone.

Technical Details

  • The form has novalidate, so the script shows its own messages instead of the browser bubbles; it still uses checkValidity() for the email format. Without JavaScript, the form posts to the server.
  • New invitations are cloned from the first item (the template), and the free seat counter follows the list.
  • Replace the demo logic with your API calls, and validate on the server too.

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.