CollaborationInvite to collaborate
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callInvite 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add invite-to-collaborateReact
npx designme add invite-to-collaborate --reactThe 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.
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
- Enter, a comma, Tab, pasting or leaving the field turns typed text into chips, and duplicates are skipped.
- Backspace in an empty field removes the last chip, and each chip has its own remove button.
- Invalid addresses show a warning chip with a "Not a valid email" tip, and Send stays disabled until every chip is valid.
- The role menu lists
roleswith a shortdetaileach; the default roles are Can edit, Can comment and Can view. onSendreceives the valid addresses and the role; a promise shows a spinner, then the chips clear. The button reads "Send 2 invites" for more than one.suggestionsadd people in one click, andlinkadds a Copy link button that confirms with Link copied.
- The role options are
menuitemradioitems in a menu labelled Role, witharia-checkedon the current role. - The role button reports
aria-expanded, and Escape closes the menu and returns focus to it. - From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
- Each chip's remove button is labelled "Remove" followed by the address, and the input is labelled Email addresses.
- The send button sets
aria-busywhile invites are going out.
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>
);
}
Props
Everything InviteToCollaborate takes.
| Prop | Type | What it does |
|---|---|---|
title | ReactNode | |
subtitle | ReactNode | |
emails | string[] | The addresses added so far, shown as chips. Leave it out to let the field keep them |
defaultEmails | string[] | |
onEmailsChange | (emails: string[]) => void | |
draft | string | The text being typed. Enter, a comma, Tab, a paste or leaving the field turns it into chips |
defaultDraft | string | |
onDraftChange | (draft: string) => void | |
roles | InviteToCollaborateRole[] | |
role | string | The role label the invites get. Leave it out to let the menu change it |
defaultRole | string | |
onRoleChange | (role: string) => void | |
suggestions | InviteToCollaborateSuggestion[] | People to add in one click. A picked one leaves the row |
members | InviteToCollaborateMember[] | 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 |
link | string | Adds Copy link, which copies this |
copyLabel | string | |
copiedLabel | string | |
placeholder | string | |
validate | (email: string) => boolean | Which addresses are valid. Defaults to anything shaped like [email protected] |
colors | string[] | Avatar colours for the chips, in turn |
className | string |
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(); }));
});
Questions
What people ask about it.
- How do I add the invited people to the members list after sending?
- In
onSend, append them to themembersyou pass in, withpending: trueand 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
roleswith alabeland optionaldetailfor each. The first role is selected unless you setdefaultRoleor controlrole.
More in Collaboration
Threads, inboxes and boards for teams working alongside an agent.