Components

Getting started

Components109

More

Team members

Settings → Members, done properly: invited addresses fly out of the field onto their rows, role pills grow into the list of roles, and seats roll.

Used forSettings → Members pagesWorkspace administrationClient portalsPlans with seat limits

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/team-members.json

Where it’s used

  • Settings → Members pages

    Invite people, change roles and remove them, in one place.

  • Workspace administration

    Seats, roles and pending invites for an organisation.

  • Client portals

    Let a client invite their own staff.

  • Plans with seat limits

    Show seats used and stop invites when none are left.

What it handles for you

Members pages are usually a table with a select per row, an Invite modal and a toast that says something happened somewhere. This puts inviting, roles, seats and removal in one place.

Instead of wiring upan invite modala members tablea role dropdown per rowa seat countera resend timera remove confirmation
  1. Invite many at once

    Paste a list of addresses, pick the role they join as, and “Invite 3” sends them.

  2. Invites land on their rows

    Each chip lifts out of the field and flies onto its new row, where the avatar opens in.

  3. Seats are counted

    “4 of 6 seats” rolls as people come and go, turns amber when none are left, and the field takes no more than fit.

  4. Roles change in place

    A role pill grows into the list of roles. If your server refuses, the old role returns and the reason shows under the name.

  5. Pending invites can be resent

    A countdown, “Resend in 0:28”, becomes a Resend link that says Sending and Sent.

  6. Removing asks first

    The bin stretches into “Remove”, a check draws, then the row folds away. You and locked members, such as an owner, can’t be removed here.

Overview

Members pages are usually a table with a select per row, an "Invite" modal and a toast that says something happened somewhere. Here the invite field sits above the list: paste a list of addresses (repeats fly into their twins), pick the role they join as, and "Invite 3" sends them. Each chip then lifts out of the field and flies down onto its new row, where the name lands as the avatar opens in. "4 of 6 seats" rolls as people come and go, and the field takes no more than the seats that are left. A role pill grows into the list of roles and folds back with the new one, a spinner riding in it while it saves; if your server refuses, the old role morphs back, the pill shakes and the reason lifts in under the name. Pending invites count down to Resend, which says Sending and Sent and starts the clock again. Removing someone stretches the bin into "Remove", draws a check and folds the row away.

States

invite
Addresses become chips in the invite field (tag input): pasted lists spill out of the caret, repeats fly into their twins and anything that isn't an address lands red. The button counts them: "Invite 3".
sending
Invite turns into "Inviting" while onInvite runs; the field holds still. If it throws, the addresses stay, the button says Try again and the reason takes the line under the field.
arrive
Each chip's copy lifts 6px out of the field and flies 680ms onto its row's name, 60ms after the one before, its outline fading as it lands; the name takes over and the avatar scales in from 0.6 out of a blur. The rows below glide down to make room.
seats
"4 of 6 seats" rolls with every invite and removal and turns amber when none are left; the field then refuses more and says how many fit.
role
The role pill's own surface grows from its right edge into the list of roles in 420ms (upward when there's no room below); the highlight glides between rows and a check draws beside the current one. Picking folds it back and the label morphs; a spinner replaces the chevron while onRoleChange runs.
refused
If onRoleChange throws, the old role morphs back, the pill shakes ±4px and the error's message lifts in over the line under the name for 3.6s.
resend
Pending invites say "Resend in 0:28", the seconds rolling down. Then Resend is a link; it morphs to Sending and Sent, the invite's time resets and the countdown opens again.
remove
The bin (or, for an invite, the crossed envelope) stretches into a red Remove / Cancel invite. A check draws, the row dims for 700ms, then folds away while the rest glide up.

Usage

usage.tsx
import { TeamMembers } from "@/components/team-members";<TeamMembers  defaultValue={members}  roles={[    { value: "admin", label: "Admin", description: "Members, billing and API keys" },    { value: "member", label: "Member", description: "Create and edit projects" },  ]}  seats={10}  onInvite={(emails, role) => api.invite(emails, role)}  onRoleChange={(member, role) => api.setRole(member.id, role)}  onRemove={(member) => api.remove(member.id)}  onResend={(member) => api.resend(member.id)}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Organisation members with Better Auth

Members and pending invitations come from the organization plugin and are mapped to plain records. Each callback is one call to its client; what it throws (a seat limit, a missing permission) is what the row or the field says. Clerk Organizations, WorkOS or your own table fit the same way.

example.tsx
"use client";import { TeamMembers, type TeamMember } from "@/components/team-members";import { authClient } from "@/lib/auth-client";const roles = [  { value: "admin", label: "Admin", description: "Members, billing and API keys" },  { value: "member", label: "Member", description: "Create and edit projects" },];const fail = (r: { error?: { message?: string } | null }) => {  if (r.error) throw new Error(r.error.message ?? "That didn't work.");};export function Members({ initial, seats, me }: { initial: TeamMember[]; seats: number; me: string }) {  return (    <TeamMembers      defaultValue={initial.map((m) => ({ ...m, you: m.id === me, locked: m.role === "owner" }))}      roles={roles}      seats={seats}      onInvite={async (emails, role) => {        const sent = await Promise.all(emails.map((email) => authClient.organization.inviteMember({ email, role })));        sent.forEach(fail);        return sent.map((r) => ({ id: r.data!.id, email: r.data!.email, role, status: "invited", invitedAt: new Date() }));      }}      onRoleChange={async (m, role) => fail(await authClient.organization.updateMemberRole({ memberId: m.id, role }))}      onRemove={async (m) =>        fail(m.status === "invited"          ? await authClient.organization.cancelInvitation({ invitationId: m.id })          : await authClient.organization.removeMember({ memberIdOrEmail: m.id }))}      onResend={async (m) => fail(await authClient.organization.inviteMember({ email: m.email, role: m.role as "admin" | "member", resend: true }))}    />  );}

Props

PropTypeDescription
valueTeamMember[]Controlled members and invites: { id, email, name?, image?, role, status?, invitedAt?, lastActiveAt?, you?, locked? }.
defaultValueTeamMember[]Starting members when uncontrolled.
onValueChange(members: TeamMember[]) => voidThe list after every invite, role change, resend and removal. New invites come first.
roles{ value, label, description? }[]Roles people can be given, in menu order. A member whose role isn't listed (an owner) shows it as plain text.
inviteRolestringThe role new invites start with. The second role by default.
seatsnumberSeats on the plan. Members and pending invites each take one; the field takes no more than are left.
onInvite(emails, role) => Promise<TeamMember[]>Sends invites and returns the new members (status "invited"). Throw to keep the addresses and show the message. Without it there is no invite field.
onRoleChange(member, role) => Promise<unknown>Saves a role; the pill changes at once and changes back if this throws. Without it roles are plain text.
onRemove(member) => Promise<unknown>Removes a member or cancels an invite. Never offered for you or locked members.
onResend(member) => Promise<unknown>Sends an invite again; the clock restarts from now.
resendAfternumberdefault 30Seconds before an invite can be sent again.
suggestionsTagInputSuggestion[] | (query) => …People to suggest in the invite field, e.g. from your directory.
titlestringdefault "Members"Heading and the list's accessible name.
descriptionstringThe line under the heading.

Accessibility and motion

  • The invite field is a tag input (combobox and chip list, with its keyboard and announcements). The role pills are buttons that open a listbox: arrows move the highlight, Enter picks, Escape folds it back and returns focus to the pill.
  • Invites, role changes, refusals, resends and removals are announced in a status region outside the list. The resend countdown is hidden from screen readers; the button says when it can be used.
  • You and locked members (an owner) can't be removed here, and a locked member's role is plain text.
  • The chips' flight is a copy in an overlay, so the field and the list each settle on their own; rows glide with additive transforms and removed rows fold away as their own detached elements.
  • Reduced motion adds, changes and removes in place; the role list opens and closes without growing.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Tag input, Text morph, Number roll, Status button.

Source

components/team-members.tsx
"use client";import * as React from "react";import { Mail, MailX, Trash2 } from "lucide-react";import { ConfirmAction } from "./confirm-action";import { NumberRoll } from "./number-roll";import { RelativeTime, toTime, type DateLike } from "./relative-time";import { StatusButton, type ActionStatus } from "./status-button";import { TagInput, type TagInputProps, type TagInputTag } from "./tag-input";import { RoleMenu, type TeamRole } from "./team-role-menu";import { TextMorph } from "./text-morph";import { useRowMotion } from "./use-row-motion";/* ───────────────────────────────────────────────────────── * TEAM MEMBERS: Settings → Members, done properly * *   invite    addresses go into the invite field (paste a list, *             duplicates fly into their twins); "Invite 3" sends *             them, and each chip lifts out of the field and *             flies down onto its new row in the list *   seats     "4 of 5 seats" rolls as people come and go; the *             field takes no more than the seats that are left *   role      the role pill grows into the list of roles and *             folds back with the new one; a spinner rides in *             the pill while it saves, and a refusal morphs the *             old role back with a shake *   resend    pending invites count down "Resend in 0:28", *             then offer Resend, which says Sending and Sent *             and starts the clock again *   remove    the bin stretches into "Remove", a check draws, *             the row folds away and the rest glide up * * Members are plain records; every change is one async * function, so it works with Clerk, Better Auth, WorkOS or * your own table. * ───────────────────────────────────────────────────────── */export type { TeamRole, DateLike };export interface TeamMember {  id: string;  email: string;  name?: string;  image?: string;  role: string;  /** "invited" until they accept. */  status?: "active" | "invited";  /** When the invite was last sent; the resend clock runs from it. */  invitedAt?: DateLike;  lastActiveAt?: DateLike | null;  /** The person looking at the page. */  you?: boolean;  /** Can't be changed or removed here, e.g. the owner. */  locked?: boolean;}export interface TeamMembersProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {  value?: TeamMember[];  defaultValue?: TeamMember[];  onValueChange?: (members: TeamMember[]) => void;  roles: TeamRole[];  /** The role new invites start with; the second role by default. */  inviteRole?: string;  /** Seats on the plan. Members and pending invites both take one. */  seats?: number;  /** Sends invites. Return the new members (status "invited"); throw to keep the addresses and say why. */  onInvite?: (emails: string[], role: string) => Promise<TeamMember[]>;  /** Saves a role. Throw to put the old one back. */  onRoleChange?: (member: TeamMember, role: string) => Promise<unknown> | unknown;  /** Removes a member or cancels an invite. */  onRemove?: (member: TeamMember) => Promise<unknown> | unknown;  /** Sends an invite again. */  onResend?: (member: TeamMember) => Promise<unknown> | unknown;  /** Seconds before an invite can be sent again. */  resendAfter?: number;  /** People to suggest in the invite field (your directory). */  suggestions?: TagInputProps["suggestions"];  title?: string;  description?: string;}type Actions = {  role: (m: TeamMember, role: string) => void;  remove: (m: TeamMember) => Promise<void>;  resend: (m: TeamMember) => Promise<void>;};const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const NONE: TeamMember[] = [];const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const SPLIT = /[\s,;]+/;const lower = (v: string) => v.toLowerCase();const validate = (v: string) => (EMAIL.test(v) ? null : `“${v}” isn’t an email address`);const message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);const two = { minimumIntegerDigits: 2 };const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const initials = (m: TeamMember) =>  (m.name ?? m.email)    .split(/[\s@._-]+/)    .filter(Boolean)    .slice(0, 2)    .map((w) => w[0]!.toUpperCase())    .join("");function Avatar({ m }: { m: TeamMember }) {  if (m.status === "invited")    return (      <span data-avatar="" className="grid size-8 shrink-0 place-items-center rounded-full border border-dashed border-border text-muted-foreground">        <Mail className="size-3.5" strokeWidth={1.75} aria-hidden="true" />      </span>    );  if (m.image)    // eslint-disable-next-line @next/next/no-img-element    return <img data-avatar="" src={m.image} alt="" className="size-8 shrink-0 rounded-full object-cover shadow-[0_0_0_1px_var(--border)]" />;  return (    <span data-avatar="" aria-hidden="true" className="grid size-8 shrink-0 place-items-center rounded-full bg-muted text-[11px] font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">      {initials(m)}    </span>  );}/** A piece of a line that opens and closes sideways (a countdown after "Resend in"). */function Reveal({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {  return (    <span      aria-hidden={!open || undefined}      className="inline-grid"      style={{        gridTemplateColumns: open ? "1fr" : "0fr",        opacity: open ? 1 : 0,        filter: open || reduced ? "none" : "blur(3px)",        transition: reduced ? "none" : `grid-template-columns 380ms ${EASE}, opacity 260ms ${EASE}, filter 260ms ${EASE}`,      }}    >      <span className="min-w-0 [clip-path:inset(-4px_0)]">{children}</span>    </span>  );}/** "Resend in 0:28", rolling down; then Resend → Sending → Sent, and the clock starts again. */function Resend({ since, after, reduced, onResend }: { since: number; after: number; reduced: boolean; onResend: () => Promise<void> }) {  const [now, setNow] = React.useState<number | null>(null);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const left = now === null ? 0 : Math.max(0, since + after * 1000 - now);  const counting = now !== null && left > 0;  React.useEffect(() => setNow(Date.now()), [since]);  React.useEffect(() => {    if (!counting) return;    const timer = window.setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000) + 10);    return () => window.clearTimeout(timer);  }, [counting, now]);  React.useEffect(() => {    if (status !== "success" && status !== "error") return;    const timer = window.setTimeout(() => setStatus("idle"), status === "success" ? 1400 : 2400);    return () => window.clearTimeout(timer);  }, [status]);  const busy = status === "pending" || status === "success";  const label = status === "pending" ? "Sending" : status === "success" ? "Sent" : status === "error" ? "Try again" : counting ? "Resend in" : "Resend";  const s = Math.ceil(left / 1000);  const ready = !counting && !busy;  return (    <button      type="button"      aria-disabled={!ready || undefined}      aria-label={counting ? "Resend invite, available shortly" : "Resend invite"}      onClick={async () => {        if (!ready) return;        setStatus("pending");        try {          await onResend();          setStatus("success");        } catch {          setStatus("error");        }      }}      className={`inline-flex cursor-pointer items-baseline rounded-sm whitespace-nowrap tabular-nums transition-colors duration-200 aria-disabled:cursor-default ${FOCUS} ${        status === "error" ? "text-red-600 dark:text-red-400" : ready ? "font-medium text-foreground underline-offset-2 hover:underline" : ""      }`}    >      <TextMorph>{label}</TextMorph>      <Reveal open={counting && status === "idle"} reduced={reduced}>        <span className="inline-flex items-baseline pl-[0.25em]">          <NumberRoll value={Math.floor(s / 60)} direction="down" />:          <NumberRoll value={s % 60} format={two} direction="down" />        </span>      </Reveal>    </button>  );}/** A refusal, lifted in over the line under the name and lifted out again. */function Note({ text, reduced }: { text?: string; reduced: boolean }) {  const [shown, setShown] = React.useState(text);  if (text && text !== shown) setShown(text);  return (    <span      aria-hidden="true"      className="pointer-events-none absolute inset-x-0 top-0 truncate text-red-600 dark:text-red-400"      style={{        opacity: text ? 1 : 0,        transform: text || reduced ? "none" : "translateY(-4px)",        filter: text || reduced ? "none" : "blur(3px)",        transition: reduced ? "none" : `opacity 260ms ${EASE} ${text ? 80 : 0}ms, transform 360ms ${EASE}, filter 260ms ${EASE}`,      }}    >      {shown}    </span>  );}interface RowProps {  m: TeamMember;  roles: TeamRole[];  saving: boolean;  shake: number;  /** Why the last change was refused; shown in place of the line under the name for a while. */  note?: string;  gone: boolean;  canEdit: boolean;  canRemove: boolean;  canResend: boolean;  resendAfter: number;  reduced: boolean;  act: React.RefObject<Actions>;}const MemberRow = React.memo(function MemberRow({ m, roles, saving, shake, note, gone, canEdit, canRemove, canResend, resendAfter, reduced, act }: RowProps) {  const invited = m.status === "invited";  const display = m.name ?? m.email;  const role = roles.find((r) => r.value === m.role)?.label ?? m.role;  const removable = canRemove && !m.locked && !m.you;  return (    <li data-row-id={m.id} className="border-t border-border first:border-t-0">      <div className="flex items-center gap-3 px-4 py-2.5 transition-opacity duration-300 @md:px-5" style={{ opacity: gone ? 0.5 : 1 }}>        <Avatar m={m} />        <div className="min-w-0 flex-1">          <p className="flex min-w-0 items-center gap-1.5 text-[13.5px] font-medium tracking-[-0.01em]">            <span data-name="" className="truncate">              {display}            </span>            {m.you && <span className="shrink-0 rounded-full px-1.5 text-[11px] font-normal text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]">You</span>}          </p>          <p className="relative mt-0.5 text-[11.5px] text-muted-foreground">            <Note text={note} reduced={reduced} />            <span              className="flex min-w-0 items-baseline gap-x-1.5"              style={{ opacity: note ? 0 : 1, filter: note && !reduced ? "blur(3px)" : "none", transition: reduced ? "none" : `opacity 220ms ${EASE}, filter 220ms ${EASE}` }}            >            {/* On a narrow card the name is enough; the address comes back when there's room. */}            {m.name && <span className="hidden min-w-0 truncate @md:inline">{m.email}</span>}            {m.name && (invited || m.lastActiveAt) && (              <span aria-hidden="true" className="hidden @md:inline">                ·              </span>            )}            {invited ? (              <>                <span className="shrink-0 @md:hidden">Invited</span>                <span className="hidden shrink-0 @md:inline">{m.invitedAt ? <RelativeTime date={m.invitedAt} prefix="Invited" /> : "Invited"}</span>                {canResend && (                  <>                    <span aria-hidden="true">·</span>                    <Resend since={m.invitedAt ? toTime(m.invitedAt) : 0} after={resendAfter} reduced={reduced} onResend={() => act.current.resend(m)} />                  </>                )}              </>            ) : m.lastActiveAt ? (              <RelativeTime className="shrink-0" date={m.lastActiveAt} prefix="Active" />            ) : null}            </span>          </p>        </div>        {canEdit && !m.locked ? (          <RoleMenu roles={roles} value={m.role} onValueChange={(r) => act.current.role(m, r)} saving={saving} shake={shake} label={`Role for ${display}`} />        ) : (          <span className="shrink-0 px-2.5 text-[12px] text-muted-foreground">{role}</span>        )}        {canRemove && (          <div className="-mr-1 flex min-w-7 shrink-0 justify-end">            {removable && (              <ConfirmAction                icon={invited ? <MailX strokeWidth={1.75} /> : <Trash2 strokeWidth={1.75} />}                label={invited ? `Cancel invite for ${display}` : `Remove ${display}`}                labels={invited ? { idle: "Cancel invite", pending: "Cancelling", success: "Cancelled", error: "Try again" } : { idle: "Remove", pending: "Removing", success: "Removed", error: "Try again" }}                onConfirm={() => act.current.remove(m)}              />            )}          </div>        )}      </div>    </li>  );});export function TeamMembers({  value,  defaultValue = NONE,  onValueChange,  roles,  inviteRole,  seats,  onInvite,  onRoleChange,  onRemove,  onResend,  resendAfter = 30,  suggestions,  title = "Members",  description = "People in this workspace and the invites waiting on them.",  className = "",  ...props}: TeamMembersProps) {  const reduced = useReducedMotion();  const [inner, setInner] = React.useState(defaultValue);  const members = value ?? inner;  const [tags, setTags] = React.useState<TagInputTag[]>([]);  const [role, setRole] = React.useState(() => inviteRole ?? roles[Math.min(1, roles.length - 1)]?.value ?? "");  const [sending, setSending] = React.useState<ActionStatus>("idle");  const [inviteError, setInviteError] = React.useState<string | null>(null);  const [saving, setSaving] = React.useState<Record<string, boolean>>({});  const [shakes, setShakes] = React.useState<Record<string, number>>({});  const [notes, setNotes] = React.useState<Record<string, string>>({});  const [gone, setGone] = React.useState<string[]>([]);  const [announce, setAnnounce] = React.useState("");  const rootRef = React.useRef<HTMLDivElement>(null);  const fieldRef = React.useRef<HTMLDivElement>(null);  const listRef = React.useRef<HTMLUListElement>(null);  const ghostRef = React.useRef<HTMLDivElement>(null);  const flyRef = React.useRef<HTMLDivElement>(null);  const flights = React.useRef(new Map<string, { rect: DOMRect; label: string }>());  const latest = React.useRef(members);  latest.current = members;  const commit = (next: TeamMember[]) => {    if (value === undefined) setInner(next);    onValueChange?.(next);  };  const used = members.length;  const left = seats === undefined ? undefined : Math.max(0, seats - used);  const valid = tags.filter((t) => !t.error);  const ranks = React.useMemo(() => new Map(members.map((m, i) => [m.id, i])), [members]);  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });  const act = React.useRef<Actions>(null!);  act.current = {    role: async (m, next) => {      const prev = m.role;      const name = m.name ?? m.email;      const label = roles.find((r) => r.value === next)?.label ?? next;      commit(latest.current.map((x) => (x.id === m.id ? { ...x, role: next } : x)));      setSaving((s) => ({ ...s, [m.id]: true }));      try {        await onRoleChange?.(m, next);        setAnnounce(`${name} is now ${label}.`);      } catch (e) {        commit(latest.current.map((x) => (x.id === m.id ? { ...x, role: prev } : x)));        setShakes((s) => ({ ...s, [m.id]: (s[m.id] ?? 0) + 1 }));        const why = message(e, `${name}'s role couldn't be changed.`);        setNotes((n) => ({ ...n, [m.id]: why }));        setAnnounce(why);        window.setTimeout(() => setNotes((n) => (n[m.id] === why ? Object.fromEntries(Object.entries(n).filter(([k]) => k !== m.id)) : n)), 3600);      } finally {        setSaving((s) => Object.fromEntries(Object.entries(s).filter(([k]) => k !== m.id)));      }    },    remove: async (m) => {      await onRemove?.(m);      setGone((g) => [...g, m.id]);      setAnnounce(m.status === "invited" ? `Invite for ${m.email} cancelled.` : `${m.name ?? m.email} removed.`);      // Long enough for the check to draw; then the row folds away.      window.setTimeout(() => {        commit(latest.current.filter((x) => x.id !== m.id));        setGone((g) => g.filter((id) => id !== m.id));      }, 700);    },    resend: async (m) => {      await onResend?.(m);      commit(latest.current.map((x) => (x.id === m.id ? { ...x, invitedAt: new Date() } : x)));      setAnnounce(`Invite sent to ${m.email} again.`);    },  };  const invite = async () => {    if (!onInvite || !valid.length || sending === "pending") return;    setSending("pending");    setInviteError(null);    try {      const added = await onInvite(        valid.map((t) => t.value),        role,      );      // Where each chip sits now, so its row can fly out of it.      const at = new Map<string, { rect: DOMRect; label: string }>();      for (const t of valid) {        const el = fieldRef.current?.querySelector<HTMLElement>(`[data-token="${CSS.escape(t.id)}"]`);        if (el) at.set(t.value.toLowerCase(), { rect: el.getBoundingClientRect(), label: t.label ?? t.value });      }      flights.current = at;      commit([...added, ...latest.current]);      setTags((ts) => ts.filter((t) => t.error));      setSending("success");      setAnnounce(`${added.length} ${added.length === 1 ? "invite" : "invites"} sent.`);    } catch (e) {      setSending("error");      setInviteError(message(e, "The invites couldn't be sent. Try again."));    }  };  // New rows that came from chips: each chip's copy flies from the field onto its row's name.  React.useLayoutEffect(() => {    const queued = flights.current;    if (!queued.size) return;    flights.current = new Map();    const root = rootRef.current;    const layer = flyRef.current;    const list = listRef.current;    if (!root || !layer || !list || reduced) return;    const box = root.getBoundingClientRect();    let i = 0;    for (const m of members) {      const from = queued.get(m.email.toLowerCase());      if (!from) continue;      const row = list.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(m.id)}"]`);      const name = row?.querySelector<HTMLElement>("[data-name]");      const avatar = row?.querySelector<HTMLElement>("[data-avatar]");      if (!row || !name) continue;      const to = name.getBoundingClientRect();      const copy = document.createElement("span");      copy.textContent = from.label;      copy.className =        "absolute left-0 top-0 inline-flex h-7 items-center whitespace-nowrap rounded-full bg-background px-2.5 text-[13px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] will-change-transform";      layer.appendChild(copy);      const x0 = from.rect.left - box.left;      const y0 = from.rect.top - box.top + (from.rect.height - 28) / 2;      const x1 = to.left - box.left - 10;      const y1 = to.top - box.top + to.height / 2 - 14;      const delay = i++ * 60;      // Lift off, travel, then hand over to the row's own name: the copy blurs out as the name sharpens in.      const flight = copy.animate(        [          { transform: `translate(${x0}px, ${y0}px)`, opacity: 1, filter: "blur(0px)", boxShadow: "inset 0 0 0 1px var(--border)", easing: "cubic-bezier(0.3,0,0.2,1)" },          { transform: `translate(${x0}px, ${y0 - 6}px)`, opacity: 1, filter: "blur(0px)", offset: 0.14, easing: EASE },          { transform: `translate(${x1}px, ${y1}px)`, opacity: 1, filter: "blur(0px)", boxShadow: "inset 0 0 0 1px transparent", backgroundColor: "transparent", offset: 0.7, easing: "linear" },          { transform: `translate(${x1}px, ${y1}px)`, opacity: 0, filter: "blur(4px)", boxShadow: "inset 0 0 0 1px transparent", backgroundColor: "transparent" },        ],        { duration: 680, delay, fill: "both" },      );      flight.onfinish = flight.oncancel = () => copy.remove();      name.animate(        [          { opacity: 0, filter: "blur(4px)" },          { opacity: 0, filter: "blur(4px)", offset: (delay + 680 * 0.66) / (delay + 680) },          { opacity: 1, filter: "blur(0px)" },        ],        { duration: delay + 680, easing: "linear" },      );      avatar?.animate([{ opacity: 0, transform: "scale(0.6)", filter: "blur(3px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 420, delay: delay + 360, easing: EASE, fill: "backwards" });    }  }, [members, reduced]);  const count = valid.length;  return (    <div ref={rootRef} className={`@container relative w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>      <div className="flex items-start justify-between gap-3 px-4 pt-4 pb-3.5 @md:px-5 @md:pt-5">        <div className="min-w-0">          <p className="flex items-baseline gap-2 text-[14px] font-medium tracking-[-0.01em]">            {title}            <span className="text-[12px] font-normal text-muted-foreground tabular-nums">              <NumberRoll value={members.filter((m) => m.status !== "invited").length} />            </span>          </p>          <p className="mt-0.5 text-[12px] text-muted-foreground">{description}</p>        </div>        {seats !== undefined && (          <p className={`flex shrink-0 items-baseline whitespace-nowrap pt-0.5 text-[11.5px] tabular-nums transition-colors duration-300 ${left === 0 ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground"}`}>            <NumberRoll value={used} />            &nbsp;of&nbsp;            <NumberRoll value={seats} />            &nbsp;<TextMorph>{seats === 1 ? "seat" : "seats"}</TextMorph>          </p>        )}      </div>      {onInvite && (        <div ref={fieldRef} className="px-4 pb-4 @md:px-5">          <TagInput            value={tags}            onValueChange={(next) => {              setTags(next);              setInviteError(null);              if (sending !== "pending") setSending("idle");            }}            label="Invite by email"            placeholder="Emails, separated by commas"            hint={inviteError ?? (left === 0 ? "Every seat is taken. Remove someone or add seats to invite more." : "Paste a list or pick from your directory.")}            validate={validate}            normalize={lower}            separators={SPLIT}            suggestions={suggestions}            createLabel={(text) => `Invite “${text}”`}            max={left}            limitMessage={left === 1 ? "One seat left on your plan." : `${left} seats left on your plan.`}            collapse={2}            disabled={sending === "pending"}          />          <div className="mt-3 flex items-center justify-end gap-2">            <RoleMenu roles={roles} value={role} onValueChange={setRole} label="Role for new members" prefix="as" />            <StatusButton              size="sm"              status={sending}              labels={{ idle: count > 1 ? `Invite ${count}` : "Invite", pending: "Inviting", success: "Invited", error: "Try again" }}              onClick={invite}              onReset={() => setSending("idle")}              aria-disabled={(!count && sending === "idle") || undefined}              className={!count && sending === "idle" ? "pointer-events-none opacity-50" : ""}            />          </div>        </div>      )}      <div className="relative border-t border-border">        <ul ref={listRef} aria-label={title} className="relative">          {members.map((m) => (            <MemberRow              key={m.id}              m={m}              roles={roles}              saving={Boolean(saving[m.id])}              shake={shakes[m.id] ?? 0}              note={notes[m.id]}              gone={gone.includes(m.id)}              canEdit={Boolean(onRoleChange)}              canRemove={Boolean(onRemove)}              canResend={Boolean(onResend)}              resendAfter={resendAfter}              reduced={reduced}              act={act}            />          ))}        </ul>        {/* Rows that went: their own elements, folding away where they stood (see use-row-motion). */}        <div ref={ghostRef} aria-hidden="true" className="pointer-events-none absolute inset-0" />      </div>      {/* Chips on their way from the field to their rows. */}      <div ref={flyRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20" />      <span className="sr-only" role="status">        {announce}      </span>    </div>  );}
components/team-role-menu.tsx
"use client";import * as React from "react";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * ROLE MENU: a role pill that grows into the list of roles * *   open      the pill's own surface grows from its right edge *             into the panel (size and radius ease) while its *             label fades into the list; it opens upward when *             there's no room below *   pick      the panel folds back into the pill and the label *             morphs to the new role; while it saves, the *             chevron gives way to a small spinner *   refuse    if saving fails the label morphs back and the *             pill gives a short shake * ───────────────────────────────────────────────────────── */export interface TeamRole {  value: string;  label: string;  /** One line under the role in the menu: what it can do. */  description?: string;}export interface RoleMenuProps {  roles: TeamRole[];  value: string;  onValueChange: (role: string) => void;  /** Shows a spinner in place of the chevron. */  saving?: boolean;  /** Bumped by the parent to shake the pill (a save failed). */  shake?: number;  /** Accessible name, e.g. "Role for Maya Chen". */  label: string;  /** Said before the role inside the pill, e.g. "as". */  prefix?: string;  disabled?: boolean;  className?: string;}type Phase = "closed" | "opening" | "open" | "closing";const EASE = "cubic-bezier(0.16,1,0.3,1)";const DURATION = 420;const PANEL = 248;const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);function Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3" style={{ transform: up ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 320ms ${EASE}` }}>      <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />    </svg>  );}/** The pill's insides, shared by the real pill and the copy that fades out as the surface grows. */function PillFace({ prefix, label, saving, up, reduced }: { prefix?: string; label: string; saving: boolean; up: boolean; reduced: boolean }) {  return (    <>      {prefix && <span className="text-muted-foreground">{prefix}&nbsp;</span>}      <TextMorph>{label}</TextMorph>      <span className="relative ml-1 grid size-3 place-items-center text-muted-foreground">        <span className="absolute inset-0 grid place-items-center" style={{ opacity: saving ? 0 : 1, transform: saving ? "scale(0.6)" : "none", transition: reduced ? "none" : `opacity 200ms ${EASE}, transform 320ms ${EASE}` }}>          <Chevron up={up} reduced={reduced} />        </span>        <span          className={`absolute inset-0.5 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${saving ? "animate-spin" : ""}`}          style={{ opacity: saving ? 1 : 0, transition: reduced ? "none" : `opacity 200ms ${EASE}` }}        />      </span>    </>  );}export function RoleMenu({ roles, value, onValueChange, saving = false, shake = 0, label, prefix, disabled = false, className = "" }: RoleMenuProps) {  const id = React.useId();  const reduced = useReducedMotion();  const [phase, setPhase] = React.useState<Phase>("closed");  const [active, setActive] = React.useState(0);  const [pill, setPill] = React.useState({ width: 0, height: 28 });  const [panelHeight, setPanelHeight] = React.useState(0);  const [fit, setFit] = React.useState({ up: false, width: PANEL, shift: 0 });  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });  const [quiet, setQuiet] = React.useState(false);  const wrapRef = React.useRef<HTMLDivElement>(null);  const triggerRef = React.useRef<HTMLButtonElement>(null);  const panelRef = React.useRef<HTMLDivElement>(null);  const listRef = React.useRef<HTMLDivElement>(null);  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);  const timer = React.useRef(0);  const selected = roles.find((r) => r.value === value);  const expanded = phase === "open";  const mounted = phase !== "closed";  const measureFit = () => {    const wrap = wrapRef.current;    if (!wrap) return;    const rect = wrap.getBoundingClientRect();    const viewport = document.documentElement.clientWidth;    const width = Math.min(PANEL, viewport - 32);    // Anchored at the pill's right edge; pushed right if that would leave the screen.    const shift = Math.max(0, 16 - (rect.right - width));    const estimate = roles.length * 52 + 12;    const up = window.innerHeight - rect.top < estimate + 24 && rect.bottom > estimate + 24;    setFit((f) => (f.up === up && f.width === width && f.shift === shift ? f : { up, width, shift }));  };  const open = () => {    if (disabled || saving || phase === "open" || phase === "opening") return;    window.clearTimeout(timer.current);    measureFit();    setActive(Math.max(0, roles.findIndex((r) => r.value === value)));    setPhase(reduced ? "open" : "opening");  };  const close = React.useCallback(    (refocus = true) => {      window.clearTimeout(timer.current);      if (refocus) triggerRef.current?.focus({ preventScroll: true });      if (reduced) return setPhase("closed");      setPhase("closing");      timer.current = window.setTimeout(() => setPhase("closed"), DURATION);    },    [reduced],  );  React.useEffect(() => {    if (phase !== "opening") return;    let inner = 0;    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open"))));    return () => {      cancelAnimationFrame(outer);      cancelAnimationFrame(inner);    };  }, [phase]);  React.useEffect(() => () => window.clearTimeout(timer.current), []);  React.useLayoutEffect(() => {    const trigger = triggerRef.current;    if (!trigger) return;    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));    measure();    const observer = new ResizeObserver(measure);    observer.observe(trigger);    return () => observer.disconnect();  }, []);  React.useLayoutEffect(() => {    const panel = panelRef.current;    if (!panel) return;    setPanelHeight(panel.offsetHeight);  }, [mounted, roles]);  React.useLayoutEffect(() => {    const row = rowRefs.current[active];    if (!row) return;    const top = row.offsetTop;    const height = row.offsetHeight;    setHighlight((h) => (h.top === top && h.height === height && h.ready === (h.ready || expanded) ? h : { top, height, ready: h.ready || expanded }));  }, [active, expanded, mounted]);  React.useEffect(() => {    if (!mounted) setHighlight({ top: 0, height: 0, ready: false });  }, [mounted]);  React.useEffect(() => {    if (expanded) listRef.current?.focus({ preventScroll: true });  }, [expanded]);  React.useEffect(() => {    if (!expanded) return;    const onDown = (e: PointerEvent) => {      if (!wrapRef.current?.contains(e.target as Node)) close(false);    };    document.addEventListener("pointerdown", onDown);    return () => document.removeEventListener("pointerdown", onDown);  }, [expanded, close]);  // A failed save: the pill shakes where it stands.  React.useEffect(() => {    if (!shake || reduced) return;    triggerRef.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [shake, reduced]);  const choose = (role: TeamRole) => {    close();    if (role.value !== value) onValueChange(role.value);  };  const onListKey = (e: React.KeyboardEvent) => {    const last = roles.length - 1;    const move = (to: number) => {      e.preventDefault();      setActive(Math.max(0, Math.min(last, to)));    };    if (e.key === "ArrowDown") move(active === last ? 0 : active + 1);    else if (e.key === "ArrowUp") move(active === 0 ? last : active - 1);    else if (e.key === "Home") move(0);    else if (e.key === "End") move(last);    else if (e.key === "Enter" || e.key === " ") {      e.preventDefault();      if (roles[active]) choose(roles[active]);    } else if (e.key === "Tab") close(false);  };  const width = expanded ? fit.width : pill.width;  const height = expanded ? panelHeight : pill.height;  const t = reduced ? "none" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`;  const listId = `${id}-roles`;  const roleLabel = selected?.label ?? value;  return (    <div      ref={wrapRef}      className={`relative inline-flex shrink-0 ${className}`}      onKeyDown={(e) => {        setQuiet(false);        if (e.key === "Escape" && expanded) {          e.preventDefault();          e.stopPropagation();          close();        }      }}      onBlur={(e) => {        if (expanded && document.hasFocus() && !wrapRef.current?.contains(e.relatedTarget as Node)) close(false);      }}    >      <button        ref={triggerRef}        type="button"        aria-haspopup="listbox"        aria-expanded={expanded}        aria-controls={mounted ? listId : undefined}        aria-label={`${label}: ${roleLabel}`}        aria-busy={saving || undefined}        disabled={disabled}        onClick={() => (expanded ? close() : open())}        onPointerDown={() => setQuiet(true)}        className={`inline-flex h-7 cursor-pointer items-center whitespace-nowrap rounded-full px-2.5 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent aria-busy:cursor-default ${quiet ? "outline-none" : FOCUS}`}      >        <PillFace prefix={prefix} label={roleLabel} saving={saving} up={expanded} reduced={reduced} />      </button>      {mounted && (        <div          onPointerDown={(e) => {            e.preventDefault();            setQuiet(true);          }}          className="absolute z-30 overflow-hidden bg-popover text-popover-foreground"          style={{            [fit.up ? "bottom" : "top"]: 0,            right: expanded ? -fit.shift : 0,            width,            height,            borderRadius: expanded ? 16 : pill.height / 2,            // The pill's own hairline, the whole way: it is the same surface.            boxShadow: "inset 0 0 0 1px var(--border)",            transition: t,          }}        >          {/* The pill's label, fading out as the surface grows and back in as it folds. */}          <span            aria-hidden="true"            className={`pointer-events-none absolute right-0 inline-flex h-7 items-center whitespace-nowrap px-2.5 text-[12px] font-medium ${fit.up ? "bottom-0" : "top-0"}`}            style={{ opacity: expanded ? 0 : 1, transition: reduced ? "none" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}          >            <PillFace prefix={prefix} label={roleLabel} saving={saving} up={expanded} reduced={reduced} />          </span>          <div            ref={panelRef}            className={`absolute right-0 p-1 ${fit.up ? "bottom-0" : "top-0"}`}            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? "none" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}          >            <div              ref={listRef}              id={listId}              role="listbox"              tabIndex={-1}              aria-label={label}              aria-activedescendant={`${id}-r${active}`}              onKeyDown={onListKey}              className="relative outline-none"            >              <span                aria-hidden="true"                className="pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent"                style={{                  height: highlight.height,                  transform: `translateY(${highlight.top}px)`,                  opacity: highlight.ready ? 1 : 0,                  transition: highlight.ready && !reduced ? `transform 300ms ${EASE}, height 300ms ${EASE}, opacity 200ms` : "none",                }}              />              {roles.map((r, i) => {                const on = r.value === value;                return (                  <div                    key={r.value}                    ref={(el) => {                      rowRefs.current[i] = el;                    }}                    id={`${id}-r${i}`}                    role="option"                    aria-selected={on}                    onPointerMove={() => setActive(i)}                    onClick={() => choose(r)}                    className="relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-2"                  >                    <span className="min-w-0 flex-1">                      <span className="block text-[12.5px] font-medium">{r.label}</span>                      {r.description && <span className="mt-0.5 block text-[11.5px] leading-[1.4] text-muted-foreground">{r.description}</span>}                    </span>                    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="mt-0.5 size-3.5 shrink-0">                      <path                        d="M3.5 8.5 6.5 11.5 12.5 4.5"                        stroke="currentColor"                        strokeWidth="1.8"                        strokeLinecap="round"                        strokeLinejoin="round"                        pathLength={1}                        strokeDasharray={1}                        style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} 160ms` }}                      />                    </svg>                  </div>                );              })}            </div>          </div>        </div>      )}    </div>  );}
components/confirm-action.tsx
"use client";import * as React from "react";import { StatusButton, type ActionStatus } from "./status-button";/* ───────────────────────────────────────────────────────── * CONFIRM ACTION: an icon that asks before it acts * *   ask       a click and the icon's own circle stretches into *             a pill: the confirm button opens out to its left *             and the icon turns into a cross that takes it back *   act       the confirm button is a status button, so it says *             Revoking, Revoked or Try again in place *   leave     Escape, the cross, or a click or focus anywhere *             else folds it back into the icon * * Nothing opens over the row it belongs to; the question sits * where the icon was. * ───────────────────────────────────────────────────────── */export interface ConfirmActionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> {  icon: React.ReactNode;  /** The icon button's accessible name, e.g. "Revoke Production key". */  label: string;  /** The confirm button's words per state, e.g. { idle: "Revoke", pending: "Revoking", success: "Revoked" }. */  labels?: Partial<Record<ActionStatus, string>>;  /** "danger" makes the confirm red. */  tone?: "danger" | "default";  /** Runs once on confirm. Throw (or reject) to show Try again. */  onConfirm: () => Promise<unknown> | unknown;  /** Back to the icon this long after success (ms); 0 keeps "done" until the row goes. */  resetAfter?: number;  disabled?: boolean;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => {  const query = window.matchMedia(reducedQuery);  query.addEventListener("change", onChange);  return () => query.removeEventListener("change", onChange);};const useReducedMotion = () =>  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);function Cross() {  return (    <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3.5">      <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />    </svg>  );}export function ConfirmAction({  icon,  label,  labels,  tone = "danger",  onConfirm,  resetAfter = 0,  disabled = false,  className = "",  ...props}: ConfirmActionProps) {  const reduced = useReducedMotion();  const [asking, setAsking] = React.useState(false);  const [status, setStatus] = React.useState<ActionStatus>("idle");  const wrapRef = React.useRef<HTMLDivElement>(null);  const iconRef = React.useRef<HTMLButtonElement>(null);  const run = React.useRef(0);  const busy = status === "pending" || status === "success";  const cancel = React.useCallback(    (refocus: boolean) => {      if (status === "pending") return;      run.current++;      setAsking(false);      setStatus("idle");      if (refocus) iconRef.current?.focus({ preventScroll: true });    },    [status],  );  // The question is open: its button takes focus once it's there to take it.  React.useEffect(() => {    if (asking && status === "idle") wrapRef.current?.querySelector<HTMLButtonElement>("[data-confirm]")?.focus({ preventScroll: true });  }, [asking, status]);  React.useEffect(() => {    if (!asking) return;    const onDown = (e: PointerEvent) => {      if (!wrapRef.current?.contains(e.target as Node)) cancel(false);    };    document.addEventListener("pointerdown", onDown);    return () => document.removeEventListener("pointerdown", onDown);  }, [asking, cancel]);  const confirm = async () => {    if (busy) return;    const mine = ++run.current;    setStatus("pending");    try {      await onConfirm();      if (run.current === mine) setStatus("success");    } catch {      if (run.current === mine) setStatus("error");    }  };  const t = `${reduced ? 0 : 420}ms ${EASE}`;  const red = tone === "danger" ? "text-red-600! hover:bg-red-500/10! dark:text-red-400!" : "";  return (    <div      ref={wrapRef}      className={`relative inline-flex h-7 shrink-0 items-center rounded-full ${className}`}      onKeyDown={(e) => {        if (e.key === "Escape" && asking) {          e.preventDefault();          e.stopPropagation();          cancel(true);        }      }}      onBlur={(e) => {        if (asking && !busy && document.hasFocus() && !wrapRef.current?.contains(e.relatedTarget as Node)) cancel(false);      }}      {...props}    >      {/* The icon's circle, stretched into the pill round the question. */}      <span        aria-hidden="true"        className="pointer-events-none absolute inset-0 rounded-full"        style={{ boxShadow: `inset 0 0 0 1px ${asking ? "var(--border)" : "transparent"}`, transition: `box-shadow ${t}` }}      />      <span        className="grid"        inert={!asking}        style={{          gridTemplateColumns: asking ? "1fr" : "0fr",          opacity: asking ? 1 : 0,          filter: asking || reduced ? "none" : "blur(3px)",          transition: `grid-template-columns ${t}, opacity ${reduced ? 0 : asking ? 320 : 160}ms ${EASE}, filter ${t}`,        }}      >        <span className="min-w-0 [clip-path:inset(-2px_0_-2px_0)]">          <span className="flex w-max items-center pl-0.5">            <StatusButton              data-confirm=""              size="sm"              variant="ghost"              status={status}              labels={{ idle: "Confirm", pending: "Working", success: "Done", error: "Try again", ...labels }}              onClick={confirm}              onReset={resetAfter ? () => cancel(false) : undefined}              resetAfter={resetAfter || undefined}              className={`h-[26px]! ${status === "error" ? "" : red}`}            />          </span>        </span>      </span>      <button        ref={iconRef}        type="button"        aria-label={asking ? "Cancel" : label}        aria-expanded={asking}        disabled={disabled}        onClick={() => (asking ? cancel(true) : setAsking(true))}        className={`relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-40 aria-disabled:cursor-default ${FOCUS}`}        aria-disabled={busy || undefined}      >        {[icon, <Cross key="x" />].map((glyph, i) => {          const on = (i === 1) === asking;          return (            <span              key={i}              aria-hidden="true"              className="absolute inset-0 grid place-items-center [&_svg]:size-3.5"              style={{                opacity: on ? 1 : 0,                transform: on ? "none" : "scale(0.6)",                filter: on || reduced ? "none" : "blur(3px)",                transition: reduced ? "none" : `opacity 260ms ${EASE}, transform 380ms ${EASE}, filter 260ms ${EASE}`,              }}            >              {glyph}            </span>          );        })}      </button>    </div>  );}
components/relative-time.tsx
"use client";import * as React from "react";import { NumberRoll } from "./number-roll";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * RELATIVE TIME: "2 min ago" that keeps itself true * *   tick      it re-renders only when the words would change: *             on the next minute under an hour, the next hour *             under a day; nothing runs between *   roll      the number rolls up ("2 min" → "3 min") and the *             unit morphs when it changes ("59 min" → "1 h") *   date      past a week it settles on the date ("12 Sep") * * Until it's mounted it renders the date, so the server and the * first paint agree; the hover title is always the full time. * ───────────────────────────────────────────────────────── */export type DateLike = Date | string | number;export interface RelativeTimeProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {  date: DateLike;  /** Said before the time, e.g. "Last used". */  prefix?: string;  /** Under a minute. */  justNow?: string;  locales?: string | string[];}const MINUTE = 60_000;const HOUR = 60 * MINUTE;const DAY = 24 * HOUR;export const toTime = (d: DateLike) => (d instanceof Date ? d.getTime() : typeof d === "number" ? d : new Date(d).getTime());const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];/** "12 Sep", with the year when it isn't this one; pass locales to use the browser's own format. */export function formatDate(d: DateLike, locales?: string | string[]) {  const t = new Date(toTime(d));  const sameYear = t.getFullYear() === new Date().getFullYear();  if (locales) return t.toLocaleDateString(locales, sameYear ? { day: "numeric", month: "short" } : { day: "numeric", month: "short", year: "numeric" });  return `${t.getDate()} ${MONTHS[t.getMonth()]}${sameYear ? "" : ` ${t.getFullYear()}`}`;}type Parts = { n: number | null; unit: string; next: number };function parts(at: number, now: number, justNow: string, locales?: string | string[]): Parts {  const ago = Math.max(0, now - at);  if (ago < MINUTE) return { n: null, unit: justNow, next: MINUTE - ago };  if (ago < HOUR) return { n: Math.floor(ago / MINUTE), unit: "min ago", next: MINUTE - (ago % MINUTE) };  if (ago < DAY) {    const h = Math.floor(ago / HOUR);    return { n: h, unit: h === 1 ? "hour ago" : "hours ago", next: HOUR - (ago % HOUR) };  }  if (ago < 7 * DAY) {    const d = Math.floor(ago / DAY);    return { n: d, unit: d === 1 ? "day ago" : "days ago", next: DAY - (ago % DAY) };  }  return { n: null, unit: formatDate(at, locales), next: 0 };}const subscribeMounted = () => () => {};export function RelativeTime({ date, prefix, justNow = "just now", locales, className = "", ...props }: RelativeTimeProps) {  const at = toTime(date);  const mounted = React.useSyncExternalStore(subscribeMounted, () => true, () => false);  const [now, setNow] = React.useState(() => Date.now());  const p = parts(at, mounted ? now : at + 8 * DAY, justNow, locales);  // One timer, set for the moment the words change.  React.useEffect(() => {    if (!mounted || !p.next) return;    const timer = window.setTimeout(() => setNow(Date.now()), p.next + 50);    return () => window.clearTimeout(timer);  }, [mounted, p.next, now]);  React.useEffect(() => setNow(Date.now()), [at]);  const iso = new Date(at).toISOString();  return (    <time      dateTime={iso}      title={new Date(at).toLocaleString(locales ?? "en-GB", { dateStyle: "medium", timeStyle: "short" })}      className={`inline-flex items-baseline whitespace-nowrap tabular-nums ${className}`}      {...props}    >      {prefix && <span>{prefix}&nbsp;</span>}      {/* Remounted once on the client, so the first real words appear without a morph. */}      <Words key={mounted ? "client" : "server"} n={p.n} unit={p.unit} />    </time>  );}function Words({ n, unit }: { n: number | null; unit: string }) {  return (    <>      <span        aria-hidden={n === null || undefined}        className="inline-grid transition-[grid-template-columns] duration-[380ms] ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none"        style={{ gridTemplateColumns: n === null ? "0fr" : "1fr" }}      >        <span className="min-w-0 [clip-path:inset(-4px_0)]">          <span className="inline-flex items-baseline pr-[0.25em]">            <NumberRoll value={n ?? 1} direction="up" />          </span>        </span>      </span>      <TextMorph>{unit}</TextMorph>    </>  );}
components/use-row-motion.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * ROW MOTION: rows that keep their place on screen * *   glide     after any render (a sort, a filter, new data from *             the server) every row that moved glides from its *             old place to its new one. Glides are additive, so *             a change that lands mid-glide adds its own offset *             instead of restarting anything *   travel    a row that sorts onto the page slides in from the *             side where it ranked before; one that sorts off *             slides out toward its new rank *   enter     a row that's new to the list fades in where it *             lands while the rows below glide clear of it *   leave     a row that's gone folds away where it stood. Its *             copy is the row's own element, detached by React *             and moved into a layer React never renders into, *             so a leaving row costs no render at all *   stagger   a sort can ask for the rows to set off one after *             another, top first * * Positions are layout offsets (transforms ignored), kept after * every render and whenever the list changes size (a row * opening its detail), so they're never stale. Rows are found * by data-row-id; ranks are their places in the whole sorted * list, not just the page. * ───────────────────────────────────────────────────────── */type Seen = { top: number; height: number; el: HTMLElement };const EASE = "cubic-bezier(0.16,1,0.3,1)";const GLIDE = 480;export function useRowMotion(  container: React.RefObject<HTMLElement | null>,  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },) {  const seen = React.useRef(new Map<string, Seen>());  const lastRanks = React.useRef(ranks);  const lastKey = React.useRef(resetKey);  const staggerNext = React.useRef(false);  const read = React.useCallback(() => {    const out = new Map<string, Seen>();    container.current?.querySelectorAll<HTMLElement>("[data-row-id]").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));    return out;  }, [container]);  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */  const leave = (was: Seen, dy: number) => {    const layer = ghosts.current;    const el = was.el;    if (!layer || el.isConnected) return;    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any    // glide still on the element.    el.removeAttribute("data-row-id");    el.removeAttribute("tabindex");    el.setAttribute("aria-hidden", "true");    el.inert = true;    Object.assign(el.style, { position: "absolute", left: "0", right: "0", top: `${was.top}px`, height: `${was.height}px`, margin: "0", overflow: "hidden" });    layer.appendChild(el);    const a = el.animate(      dy        ? [            { transform: "translateY(0px)", opacity: 1, filter: "blur(0px)" },            { transform: `translateY(${dy}px)`, opacity: 0, filter: "blur(3px)" },          ]        : [            { opacity: 1, transform: "none", filter: "blur(0px)" },            { opacity: 0, transform: "scale(0.985)", filter: "blur(4px)" },          ],      { duration: dy ? 360 : 280, easing: EASE, fill: "forwards" },    );    a.onfinish = a.oncancel = () => el.remove();  };  React.useLayoutEffect(() => {    const el = container.current;    if (!el) return;    const before = seen.current;    const after = read();    const prevRanks = lastRanks.current;    seen.current = after;    lastRanks.current = ranks;    const stagger = staggerNext.current;    staggerNext.current = false;    if (lastKey.current !== resetKey) {      // A different page: the caller slides the whole body; nothing glides and nothing folds.      lastKey.current = resetKey;      return;    }    if (reduced || !before.size) return;    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a    // quarter at most), so the list never shows holes while they travel.    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));    let order = 0;    after.forEach((now, id) => {      const row = now.el;      const was = before.get(id);      const delay = stagger ? Math.min(order++ * 16, 200) : 0;      if (was) {        const dy = was.top - now.top;        if (Math.abs(dy) < 0.5) return;        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: "translateY(0px)", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: "add", fill: "backwards" });        return;      }      const from = prevRanks.get(id);      const to = ranks.get(id);      if (from !== undefined && to !== undefined) {        // It ranked somewhere off the page: it comes in from that side.        row.animate(          [            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: "blur(3px)" },            { transform: "translateY(0px)", opacity: 1, filter: "blur(0px)" },          ],          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: "backwards" },        );        return;      }      row.animate(        [          { opacity: 0, filter: "blur(4px)" },          { opacity: 1, filter: "blur(0px)" },        ],        { duration: 420, delay: 90, easing: EASE, fill: "backwards" },      );    });    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.    before.forEach((was, id) => {      if (after.has(id)) return;      const from = prevRanks.get(id);      const to = ranks.get(id);      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);    });  });  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.  React.useEffect(() => {    const el = container.current;    if (!el) return;    const observer = new ResizeObserver(() => {      seen.current = read();    });    observer.observe(el);    return () => observer.disconnect();  }, [container, read]);  return {    /** The next change (a sort) sets the rows off one after another. */    stagger: () => {      staggerNext.current = true;    },  };}

Registry item: /r/team-members.json