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