Skip to content
Star on GitHub
CollaborationInvite to collaborate
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Invite to collaborate

Add teammates by email as chips, set their role, send. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add invite-to-collaborate
Invite to Pricing refreshThey can see frames, the brief and agent runs
Suggested
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add invite-to-collaborate

React

$npx designme add invite-to-collaborate --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Invite to collaborate is a sharing panel where a person types email addresses that turn into chips, picks a role such as Can edit or Can view, and sends the invites. Addresses that don't look valid are flagged and block sending until they are fixed or removed. Below the field it lists who already has access, with pending invites shown in amber.

  • When a project, workspace or agent run should be shared with teammates
  • When invitees need a role, such as whether they may run the agent or only comment
  • When you can suggest likely teammates for one-click adding
  • When a share link is an alternative to inviting by email
03

Usage

Import it and drive it from your own state.

Import

import { InviteToCollaborate } from '@/components/designme/invite-to-collaborate/InviteToCollaborate';

Example

The demo above as a React component, 53 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { InviteToCollaborate, type InviteToCollaborateMember } from './InviteToCollaborate';
import { useStory } from '../lib/useScript';

/* ---------- Invite to collaborate: two addresses are typed in, one of them not quite right ---------- */
const TEAM: InviteToCollaborateMember[] = [
  { name: 'Maya Kim', initials: 'MK', color: 'var(--dm-av-l)', role: 'Owner' },
  { name: 'Jon Ortiz', initials: 'JO', color: 'var(--dm-av-m)', role: 'Can edit' }
];
const COLORS = ['var(--dm-av-l)', 'var(--dm-av-m)', 'var(--dm-av-t)', 'var(--dm-av-p)', 'var(--dm-av-k)'];
const TYPED = '[email protected], lee@studio';
const wait = (ms: number) => new Promise(res => setTimeout(res, ms));

export default function InviteToCollaborateDemo() {
  const [emails, setEmails] = useState<string[]>([]);
  const [draft, setDraft] = useState('');
  const [members, setMembers] = useState(TEAM);
  // a comma turns what is typed so far into a chip, the same as typing one in the field
  const commit = (text: string) => { const part = text.trim(); if (part) setEmails(es => (es.includes(part) ? es : [...es, part])); setDraft(''); };

  // the preview adds two people on its own; clicking, focusing or typing in it takes over, and what it had typed goes
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait, taken }) => {
    await wait(900);
    taken(() => setDraft(''));
    let text = '';
    for (const ch of TYPED) {
      if (ch === ',') { commit(text); text = ''; } else { text += ch; setDraft(text); }
      await wait(40);
    }
    commit(text);
  });

  // sending takes a moment, then the invited people join the list as pending
  const send = async (valid: string[], role: string) => {
    await wait(800);
    setMembers(ms => [...ms, ...valid.map((e, i) => ({
      key: e, name: e.split('@')[0], detail: e, initials: e.split('@')[0].slice(0, 2).toUpperCase(), color: COLORS[i % COLORS.length],
      role: `Invited, ${role.toLowerCase()}`, pending: true
    }))]);
  };

  return (
    <div ref={ref} style={{ width: '100%' }}>
      <InviteToCollaborate emails={emails} onEmailsChange={setEmails} draft={draft} onDraftChange={setDraft} members={members} onSend={send}
        suggestions={[{ email: '[email protected]', initials: 'AR', color: 'var(--dm-av-p)' }, { email: '[email protected]', initials: 'SL', color: 'var(--dm-av-t)' }]}
        link="https://designme.dev/join/pricing-refresh" />
    </div>
  );
}
04

Props

Everything InviteToCollaborate takes.

PropTypeWhat it does
titleReactNode
subtitleReactNode
emailsstring[]The addresses added so far, shown as chips. Leave it out to let the field keep them
defaultEmailsstring[]
onEmailsChange(emails: string[]) => void
draftstringThe text being typed. Enter, a comma, Tab, a paste or leaving the field turns it into chips
defaultDraftstring
onDraftChange(draft: string) => void
rolesInviteToCollaborateRole[]
rolestringThe role label the invites get. Leave it out to let the menu change it
defaultRolestring
onRoleChange(role: string) => void
suggestionsInviteToCollaborateSuggestion[]People to add in one click. A picked one leaves the row
membersInviteToCollaborateMember[]Who is in already, with invites sent
onSend(emails: string[], role: string) => void | Promise<unknown>Called with the valid addresses and the role. Return a promise and the button spins until it settles; the chips clear after
linkstringAdds Copy link, which copies this
copyLabelstring
copiedLabelstring
placeholderstring
validate(email: string) => booleanWhich addresses are valid. Defaults to anything shaped like [email protected]
colorsstring[]Avatar colours for the chips, in turn
classNamestring
05

Code

The full source, as the CLI installs it.

InviteToCollaborate.tsxReact · 181 lines
'use client';
import { useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useCopied } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, enter, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './invite-to-collaborate.css';

export interface InviteToCollaborateRole {
  /** Like "Can edit" */
  label: string;
  /** Like "Change frames, run the agent" */
  detail?: string;
}

export interface InviteToCollaborateMember {
  /** Keeps the row in place. Defaults to the name */
  key?: string;
  name: string;
  /** A quiet line under the name, like the email of someone just invited */
  detail?: string;
  initials: string;
  /** The avatar colour, like 'var(--dm-av-l)' */
  color: string;
  /** Like "Owner" or "Invited, can edit" */
  role: string;
  /** Shows the role in amber, for invites not yet accepted */
  pending?: boolean;
}

export interface InviteToCollaborateSuggestion {
  email: string;
  initials: string;
  color: string;
}

export interface InviteToCollaborateProps {
  title?: ReactNode;
  subtitle?: ReactNode;
  /** The addresses added so far, shown as chips. Leave it out to let the field keep them */
  emails?: string[];
  defaultEmails?: string[];
  onEmailsChange?: (emails: string[]) => void;
  /** The text being typed. Enter, a comma, Tab, a paste or leaving the field turns it into chips */
  draft?: string;
  defaultDraft?: string;
  onDraftChange?: (draft: string) => void;
  roles?: InviteToCollaborateRole[];
  /** The role label the invites get. Leave it out to let the menu change it */
  role?: string;
  defaultRole?: string;
  onRoleChange?: (role: string) => void;
  /** People to add in one click. A picked one leaves the row */
  suggestions?: InviteToCollaborateSuggestion[];
  /** Who is in already, with invites sent */
  members?: InviteToCollaborateMember[];
  /** Called with the valid addresses and the role. Return a promise and the button spins until it settles; the chips clear after */
  onSend?: (emails: string[], role: string) => void | Promise<unknown>;
  /** Adds Copy link, which copies this */
  link?: string;
  copyLabel?: string;
  copiedLabel?: string;
  placeholder?: string;
  /** Which addresses are valid. Defaults to anything shaped like [email protected] */
  validate?: (email: string) => boolean;
  /** Avatar colours for the chips, in turn */
  colors?: string[];
  className?: string;
}

const ROLES: InviteToCollaborateRole[] = [
  { label: 'Can edit', detail: 'Change frames, run the agent' }, { label: 'Can comment', detail: 'Leave notes, no edits' }, { label: 'Can view', detail: 'Read only' }
];
const COLORS = ['var(--dm-av-l)', 'var(--dm-av-m)', 'var(--dm-av-t)', 'var(--dm-av-p)', 'var(--dm-av-k)'];
const isEmail = (e: string) => /^\S+@\S+\.\S+$/.test(e);
const initialsOf = (e: string) => e.split('@')[0].slice(0, 2).toUpperCase();

/** Add teammates by email as chips, set their role, and send the invites. */
export function InviteToCollaborate({ title = 'Invite to Pricing refresh', subtitle = 'They can see frames, the brief and agent runs', emails, defaultEmails = [], onEmailsChange, draft, defaultDraft = '', onDraftChange,
  roles = ROLES, role, defaultRole, onRoleChange, suggestions, members = [], onSend, link, copyLabel = 'Copy link', copiedLabel = 'Link copied',
  placeholder = '[email protected]', validate = isEmail, colors = COLORS, className }: InviteToCollaborateProps) {
  const [list, setList] = useControllable(emails, defaultEmails, onEmailsChange);
  const [text, setText] = useControllable(draft, defaultDraft, onDraftChange);
  const [cur, setCur] = useControllable(role, defaultRole ?? roles[0]?.label ?? '', onRoleChange);
  const menu = usePopover({ align: 'end' });
  const [copied, copy] = useCopied();
  const [busy, setBusy] = useState(false);
  const [used, setUsed] = useState<string[]>([]);

  const add = (parts: string[]) => { const next = [...list]; parts.forEach(p => { if (!next.includes(p)) next.push(p); }); setList(next); };
  const commit = (value = text) => {
    const parts = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean);
    if (!parts.length) return;
    add(parts); setText('');
  };
  const key = (e: KeyboardEvent<HTMLInputElement>) => {
    if ((e.key === 'Enter' || e.key === ',' || e.key === 'Tab') && text.trim()) { e.preventDefault(); commit(); }
    else if (e.key === 'Backspace' && !text && list.length) setList(list.slice(0, -1));
  };

  const valid = list.filter(validate);
  const send = async () => {
    setBusy(true);
    try { await onSend?.(valid, cur); } finally { setBusy(false); setList([]); }
  };

  // members invited after the first render come in one after another
  const keyOf = (m: InviteToCollaborateMember) => m.key ?? m.name;
  const [seen, setSeen] = useState(() => new Map(members.map(m => [keyOf(m), -1])));
  const fresh = members.filter(m => !seen.has(keyOf(m)));
  if (fresh.length) setSeen(new Map([...seen, ...fresh.map((m, i): [string, number] => [keyOf(m), i * 70])]));
  const delayOf = (m: InviteToCollaborateMember) => seen.get(keyOf(m)) ?? fresh.indexOf(m) * 70;

  return (
    <div data-dm="" className={cx('dm-ivx dm-panel', className)}>
      <div className="dm-ivx-h"><b>{title}</b><span className="dm-muted">{subtitle}</span></div>
      <div className="dm-ivx-f">
        <div className="dm-ivx-chips">
          {list.map((e, i) => {
            const ok = validate(e);
            const chip = (
              <span key={e} className={cx('dm-ivx-ch', !ok && 'dm-bad')}>
                {ok ? <Avatar initials={initialsOf(e)} color={colors[i % colors.length]} /> : <Icon name="alert" size="xs" />}{e}
                <button type="button" aria-label={`Remove ${e}`} onClick={() => setList(list.filter(x => x !== e))}><Icon name="x" size="xs" /></button>
              </span>
            );
            return ok ? chip : <Tip key={e} label="Not a valid email">{chip}</Tip>;
          })}
        </div>
        <input placeholder={placeholder} aria-label="Email addresses" spellCheck={false} value={text} onChange={e => setText(e.target.value)}
          onKeyDown={key} onBlur={() => commit()} onPaste={e => { const t = e.currentTarget; setTimeout(() => commit(t.value), 0); }} />
        <button type="button" {...menu.trigger} className="dm-ivx-role"><Swap>{cur}</Swap><Icon name="chev-d" size="xs" /></button>
      </div>
      <div {...menu.pop} className={cx(menu.pop.className, 'dm-ivx-rp')} role="menu" aria-label="Role">
        {roles.map(r => (
          <button type="button" key={r.label} className="dm-mi" role="menuitemradio" aria-checked={r.label === cur} onClick={() => { setCur(r.label); menu.closeSoon(); }}>
            <span>{r.label}{r.detail && <span className="dm-d">{r.detail}</span>}</span>
            <span className="dm-ck"><Icon name="check" size="xs" /></span>
          </button>
        ))}
      </div>
      {!!suggestions?.length && (
        <div className="dm-ivx-sg">
          <span className="dm-muted">Suggested</span>
          {suggestions.filter(s => !used.includes(s.email)).map(s => (
            <button type="button" key={s.email} className="dm-chip dm-sm" onClick={() => { add([s.email]); setUsed([...used, s.email]); }}>
              <Avatar initials={s.initials} color={s.color} />{s.email.split('@')[0]}
            </button>
          ))}
        </div>
      )}
      <div className="dm-ivx-mem">
        {members.map(m => {
          const d = delayOf(m), e = d >= 0 ? enter(d) : null;
          return (
            <div key={keyOf(m)} className={cx('dm-ivx-m', e?.className)} style={e?.style}>
              <Avatar initials={m.initials} color={m.color} />
              <span className="dm-t">{m.name}{m.detail && <span>{m.detail}</span>}</span>
              <span className={cx('dm-r', m.pending && 'dm-pend')}>{m.role}</span>
            </div>
          );
        })}
      </div>
      <div className="dm-ivx-ft">
        {link ? (
          <Tip label="Copied to clipboard" open={copied || undefined} off={!copied}>
            <button type="button" className={cx('dm-pill dm-ghost dm-sm dm-ivx-link', copied && 'dm-copied')} onClick={() => copy(link)}>
              <Icon name={copied ? 'check' : 'link'} size="xs" />{copied ? copiedLabel : copyLabel}
            </button>
          </Tip>
        ) : <span />}
        <button type="button" className="dm-pill dm-primary dm-sm dm-ivx-go" disabled={!busy && (!valid.length || valid.length < list.length)} aria-busy={busy} onClick={() => { if (!busy) send(); }}>
          {busy ? <Spin /> : valid.length > 1 ? `Send ${valid.length} invites` : 'Send invite'}
        </button>
      </div>
    </div>
  );
}
invite-to-collaborate.cssReact · 35 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Invite ---------- */
.dm-ivx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:10px;max-width:420px;margin:0 auto;width:100%}
.dm-ivx-h{display:flex;flex-direction:column;font-size:14px;margin:0 -14px 2px;padding:0 14px var(--dm-s-3);border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ivx-h b{font-weight:500}
.dm-ivx-h .dm-muted{font-size:12px}
.dm-ivx-f{display:flex;align-items:center;flex-wrap:wrap;gap:5px;min-height:var(--dm-field-h);padding:5px 5px 5px 6px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-ivx-f{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-ivx-f:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-ivx-chips{display:contents}
.dm-ivx-ch{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 var(--dm-s-1) 0 var(--dm-s-1);border-radius:var(--dm-r-tag);background:var(--dm-surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);font-size:12.5px;animation:dm-popin .4s var(--dm-ease) backwards}
.dm-ivx-ch .dm-av{width:18px;height:18px;font-size:8.5px}
.dm-ivx-ch.dm-bad{background:var(--dm-bad-bg);color:var(--dm-bad);box-shadow:none}
/* chips already on screen keep still when the row is redrawn; only a new one pops in */
.dm-ivx-chips .dm-ivx-ch.dm-seen{animation:none}
.dm-ivx-ch button{display:grid;color:var(--dm-muted)}@media (hover:hover) and (pointer:fine){.dm-ivx-ch button:hover{color:var(--dm-fg)}}
.dm-ivx-f input{flex:1;min-width:120px;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--dm-fg);height:28px;padding:0 6px}
@supports (corner-shape:squircle){.dm-ivx-ch{corner-shape:squircle;border-radius:var(--dm-r-tag-sq)}}
.dm-ivx-f input::placeholder{color:var(--dm-muted)}
.dm-ivx-role{display:flex;align-items:center;gap:var(--dm-s-1);height:30px;padding:0 var(--dm-s-2);border-radius:9px;font-size:12.5px;color:var(--dm-fg2);margin-left:auto}
@media (hover:hover) and (pointer:fine){.dm-ivx-role:hover{background:var(--dm-row)}}
.dm-ivx-sg{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}
.dm-ivx-sg .dm-chip .dm-av{width:18px;height:18px;font-size:8.5px;margin-left:-6px}
.dm-ivx-mem{display:flex;flex-direction:column}
.dm-ivx-m{display:flex;align-items:center;gap:10px;padding:6px 2px;font-size:13px}
.dm-ivx-m .dm-av{width:26px;height:26px;font-size:10px}
.dm-ivx-m .dm-t{flex:1;display:flex;flex-direction:column}
.dm-ivx-m .dm-t span{font-size:11.5px;color:var(--dm-muted)}
.dm-ivx-m .dm-r{font-size:12px;color:var(--dm-muted)}
.dm-ivx-m .dm-r.dm-pend{color:var(--dm-warn)}
/* the buttons sit under a hairline, with the same room above and below it */
.dm-ivx-ft{display:flex;justify-content:space-between;gap:6px;margin:0 -14px;padding:10px 14px 0;border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-ivx-go[disabled]{box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-ivx-go[disabled]:hover{box-shadow:none}}
invite-to-collaborate.htmlHTML · 13 lines
<div class="card" data-c="invite">
  <div class="stage-in">
    <div class="ivx panel">
      <div class="ivx-h"><b>Invite to Pricing refresh</b><span class="muted">They can see frames, the brief and agent runs</span></div>
      <div class="ivx-f"><div class="ivx-chips"></div><input placeholder="[email protected]" aria-label="Email addresses" spellcheck="false"><button class="ivx-role" data-pop=".ivx-rp" data-pop-align="end"><span>Can edit</span><svg class="i xs"><use href="#i-chev-d"/></svg></button></div>
      <div class="pop ivx-rp" role="menu"><button class="mi" data-close data-r="Can edit" aria-checked="true"><span>Can edit<span class="d">Change frames, run the agent</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button><button class="mi" data-close data-r="Can comment" aria-checked="false"><span>Can comment<span class="d">Leave notes, no edits</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button><button class="mi" data-close data-r="Can view" aria-checked="false"><span>Can view<span class="d">Read only</span></span><span class="ck"><svg class="i xs"><use href="#i-check"/></svg></span></button></div>
      <div class="ivx-sg"><span class="muted">Suggested</span></div>
      <div class="ivx-mem"></div>
      <div class="ivx-ft"><button class="pill ghost sm ivx-link"><svg class="i xs"><use href="#i-link"/></svg>Copy link</button><button class="pill primary sm ivx-go" disabled>Send invites</button></div>
    </div>
  </div>
</div>
invite-to-collaborate.cssHTML · 34 lines
/* ---------- Invite ---------- */
.ivx{padding:var(--s-4);display:flex;flex-direction:column;gap:10px;max-width:420px;margin:0 auto;width:100%}
.ivx-h{display:flex;flex-direction:column;font-size:14px;margin:0 -14px 2px;padding:0 14px var(--s-3);border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ivx-h b{font-weight:500}
.ivx-h .muted{font-size:12px}
.ivx-f{display:flex;align-items:center;flex-wrap:wrap;gap:5px;min-height:var(--field-h);padding:5px 5px 5px 6px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.ivx-f{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.ivx-f:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.ivx-chips{display:contents}
.ivx-ch{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 var(--s-1) 0 var(--s-1);border-radius:var(--r-tag);background:var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);font-size:12.5px;animation:popin .4s var(--ease) backwards}
.ivx-ch .av{width:18px;height:18px;font-size:8.5px}
.ivx-ch.bad{background:var(--bad-bg);color:var(--bad);box-shadow:none}
/* chips already on screen keep still when the row is redrawn; only a new one pops in */
.ivx-chips .ivx-ch.seen{animation:none}
.ivx-ch button{display:grid;color:var(--muted)}@media (hover:hover) and (pointer:fine){.ivx-ch button:hover{color:var(--fg)}}
.ivx-f input{flex:1;min-width:120px;border:0;outline:0;background:transparent;font:inherit;font-size:13.5px;color:var(--fg);height:28px;padding:0 6px}
@supports (corner-shape:squircle){.ivx-ch{corner-shape:squircle;border-radius:var(--r-tag-sq)}}
.ivx-f input::placeholder{color:var(--muted)}
.ivx-role{display:flex;align-items:center;gap:var(--s-1);height:30px;padding:0 var(--s-2);border-radius:9px;font-size:12.5px;color:var(--fg2);margin-left:auto}
@media (hover:hover) and (pointer:fine){.ivx-role:hover{background:var(--row)}}
.ivx-sg{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}
.ivx-sg .chip .av{width:18px;height:18px;font-size:8.5px;margin-left:-6px}
.ivx-mem{display:flex;flex-direction:column}
.ivx-m{display:flex;align-items:center;gap:10px;padding:6px 2px;font-size:13px}
.ivx-m .av{width:26px;height:26px;font-size:10px}
.ivx-m .t{flex:1;display:flex;flex-direction:column}
.ivx-m .t span{font-size:11.5px;color:var(--muted)}
.ivx-m .r{font-size:12px;color:var(--muted)}
.ivx-m .r.pend{color:var(--warn)}
/* the buttons sit under a hairline, with the same room above and below it */
.ivx-ft{display:flex;justify-content:space-between;gap:6px;margin:0 -14px;padding:10px 14px 0;border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.ivx-go[disabled]{box-shadow:none}
@media (hover:hover) and (pointer:fine){.ivx-go[disabled]:hover{box-shadow:none}}
invite-to-collaborate.jsHTML · 24 lines
DM.def('invite', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs')).replace(/\{chk\}/g, I('check','xs')).replace('{link}', I('link','xs'));
  const chips = $('.ivx-chips', r), inp = $('.ivx-f input', r), go = $('.ivx-go', r), mem = $('.ivx-mem', r), sg = $('.ivx-sg', r);
  let E = [], role = 'Can edit', shown = new Set();
  const COL = ['var(--av-l)','var(--av-m)','var(--av-t)','var(--av-p)','var(--av-k)'];
  const ok = e => /^\S+@\S+\.\S+$/.test(e);
  const ini = e => e.split('@')[0].slice(0, 2).toUpperCase();
  function paint(){ const seen = shown; shown = new Set(E); chips.innerHTML = E.map((e, i) => `<span class="ivx-ch${ok(e) ? '' : ' bad'}${seen.has(e) ? ' seen' : ''}" ${ok(e) ? '' : 'data-tip="Not a valid email"'}>${ok(e) ? AV(ini(e), COL[i % 5]) : I('alert','xs')}${e}<button aria-label="Remove ${e}">${I('x','xs')}</button></span>`).join('');
    $$('.ivx-ch button', chips).forEach((b, i) => b.onclick = () => { E.splice(i, 1); paint(); });
    const n = E.filter(ok).length; go.disabled = !n || E.some(e => !ok(e)); go.textContent = n > 1 ? `Send ${n} invites` : 'Send invite'; }
  function commit(){ const parts = inp.value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean); if (!parts.length) return; parts.forEach(p => { if (!E.includes(p)) E.push(p); }); inp.value = ''; paint(); }
  inp.onkeydown = e => { if (e.key === 'Enter' || e.key === ',' || e.key === 'Tab'){ if (inp.value.trim()){ e.preventDefault(); commit(); } } else if (e.key === 'Backspace' && !inp.value && E.length){ E.pop(); paint(); } };
  inp.onpaste = () => setTimeout(commit, 0); inp.onblur = commit;
  [['[email protected]','AR','var(--av-p)'],['[email protected]','SL','var(--av-t)']].forEach(s => { const b = html(`<button class="chip sm">${AV(s[1], s[2])}${s[0].split('@')[0]}</button>`); sg.appendChild(b); b.onclick = () => { if (!E.includes(s[0])){ E.push(s[0]); paint(); } b.remove(); }; });
  $$('.ivx-rp .mi', r).forEach(m => m.addEventListener('click', () => { role = m.dataset.r; $$('.ivx-rp .mi', r).forEach(x => x.setAttribute('aria-checked', x === m)); swap($('.ivx-role span', r), role); }));
  const M = [['MK','var(--av-l)','Maya Kim','Owner'],['JO','var(--av-m)','Jon Ortiz','Can edit']];
  mem.innerHTML = M.map(m => `<div class="ivx-m">${AV(m[0], m[1])}<span class="t">${m[2]}</span><span class="r">${m[3]}</span></div>`).join('');
  go.onclick = () => c.run(async () => { go.innerHTML = '<span class="spin"></span>'; await c.wait(800); E.filter(ok).forEach((e, i) => { const el = html(`<div class="ivx-m">${AV(ini(e), COL[i % 5])}<span class="t">${e.split('@')[0]}<span>${e}</span></span><span class="r pend">Invited, ${role.toLowerCase()}</span></div>`); mem.appendChild(el); enter(el, i * 70); }); E = []; paint(); });
  const lk = $('.ivx-link', r); lk.onclick = () => { copied(lk, 'https://designme.dev/join/pricing-refresh'); const t = lk.lastChild; t.textContent = 'Link copied'; clearTimeout(lk._lt); lk._lt = setTimeout(() => { t.textContent = 'Copy link'; }, 1600); };
  paint();
  // the preview adds two people on its own; clicking, focusing or typing in it takes over, and what it had typed goes
  c.auto(900, () => c.run(async () => { await c.type(inp, '[email protected]', { keep:true }); await c.step(40); commit(); await c.type(inp, ' lee@studio', { keep:true }); commit(); }));
});
06

Questions

What people ask about it.

How do I add the invited people to the members list after sending?
In onSend, append them to the members you pass in, with pending: true and a role like "Invited, can edit". Members added after the first render come in one after another.
Can I restrict invites to my company's domain?
Yes. Pass validate, a function that returns true for allowed addresses. Anything else shows as an invalid chip and keeps Send disabled.
Can I set my own roles?
Pass roles with a label and optional detail for each. The first role is selected unless you set defaultRole or control role.
07

More in Collaboration

Threads, inboxes and boards for teams working alongside an agent.

CommentsA design thread where you can @mention the agent and it replies.
NotificationsAn inbox where agent updates and mentions arrive live.
BannerAn announcement on a tint of its tone, with its own line drawing.
ReactionsQuick reactions on an agent update, with counts that pop.
Profile menuThe account menu behind an avatar: switch workspace, theme, shortcuts and sign out.
Context switcherThe organisation, project and environment you are in, each part a searchable switcher.
Filter menuAdd filters from a menu, see them as chips, and watch the count change.
Ticket listSupport tickets the agent has triaged, with suggested replies.
All 10 in Collaboration →