{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"api-keys","type":"registry:component","title":"API keys","description":"The API keys screen, done properly: the secret rises in once, and Done folds it down into the \"sk_live_••••4f2a\" it leaves behind.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["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/api-keys.tsx","type":"registry:component","content":"// Hairline UI · api-keys · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Copy, Plus, RotateCw, Trash2 } from \"lucide-react\";\nimport { ConfirmAction } from \"./confirm-action\";\nimport { NumberRoll } from \"./number-roll\";\nimport { formatDate, RelativeTime, toTime, type DateLike } from \"./relative-time\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { useRowMotion } from \"./use-row-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * API KEYS: the settings screen, done properly\n *\n *   create    \"Create key\" opens a row at the top of the list\n *             (the rows below glide down): a name, the scopes,\n *             Create. That same row becomes the new key\n *   reveal    the secret rises in under it character by\n *             character, with Copy and Done; it is never kept\n *             anywhere but on screen\n *   seal      Done folds the secret away: its prefix and last\n *             four characters fly up into the key's chip as the\n *             rest blurs out, so \"sk_live_••••4f2a\" is visibly\n *             what's left of the key you just saw\n *   roll      the old key's chip lifts away, the new secret\n *             rises in where it was, and the old key (if your\n *             server keeps it alive) counts down to its expiry\n *             in the row below\n *   revoke    the bin's own circle stretches into \"Revoke\";\n *             a check draws, then the row folds away and the\n *             rest glide up\n *\n * Keys are plain records; every change is one async function\n * that returns what your server stored.\n * ───────────────────────────────────────────────────────── */\n\nexport type { DateLike };\n\nexport interface ApiKey {\n  id: string;\n  name: string;\n  /** The part of the key you show before the dots, e.g. \"sk_live_\". */\n  prefix?: string;\n  /** The last characters of the key, e.g. \"4f2a\". */\n  last4: string;\n  scopes?: string[];\n  createdAt?: DateLike;\n  lastUsedAt?: DateLike | null;\n  /** A rolled key keeps working until then; it counts down in its row. */\n  expiresAt?: DateLike | null;\n}\n\nexport interface ApiKeyScope {\n  value: string;\n  label: string;\n}\n\nexport interface ApiKeyCreated {\n  key: ApiKey;\n  /** The whole secret, shown once. */\n  secret: string;\n}\n\nexport interface ApiKeyRolled extends ApiKeyCreated {\n  /** The old key as your server now keeps it (with expiresAt), if it still works for a while. */\n  previous?: ApiKey | null;\n}\n\nexport interface ApiKeysProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  value?: ApiKey[];\n  defaultValue?: ApiKey[];\n  onValueChange?: (keys: ApiKey[]) => void;\n  /** Creates the key. Return it with its secret; throw to keep the form and say why. */\n  onCreate: (input: { name: string; scopes: string[] }) => Promise<ApiKeyCreated>;\n  /** Replaces a key with a new secret. Without it there is no Roll button. */\n  onRoll?: (key: ApiKey) => Promise<ApiKeyRolled>;\n  /** Revokes a key. Without it there is no Revoke button. */\n  onRevoke?: (key: ApiKey) => Promise<unknown> | unknown;\n  /** What a key may do. Without it the form doesn't ask. */\n  scopes?: ApiKeyScope[];\n  /** Scopes a new key starts with; all of them by default. */\n  defaultScopes?: string[];\n  title?: string;\n  description?: string;\n  /** The name field's placeholder. */\n  placeholder?: string;\n}\n\ntype Draft = { rk: string; name: string; scopes: string[]; status: ActionStatus; error: string | null };\ntype Reveal = { id: string; secret: string; sealing: boolean };\ntype Actions = {\n  setDraft: (patch: Partial<Draft>) => void;\n  submit: () => void;\n  cancel: () => void;\n  seal: (id: string) => void;\n  roll: (key: ApiKey) => Promise<void>;\n  revoke: (key: ApiKey) => 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: ApiKey[] = [];\nconst DAY = 86_400_000;\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 masked = (k: ApiKey) => `${k.prefix ?? \"\"}••••${k.last4}`;\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\n\n/** Text as one inline-block per character, so characters can be found and flown; lines still break between them. */\nfunction Chars({ text }: { text: string }) {\n  return (\n    <>\n      {Array.from(text).map((ch, i) => (\n        <span key={i} data-ch={i} className=\"inline-block\">\n          {ch}\n        </span>\n      ))}\n    </>\n  );\n}\n\n/** A block that opens and closes by height; it keeps its last children while it closes. */\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  return (\n    <div className=\"grid\" inert={!open} style={{ gridTemplateRows: open ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 460ms ${EASE}` }}>\n      <div className=\"min-h-0 overflow-clip\">{children}</div>\n    </div>\n  );\n}\n\n/** \"Expires in 23:59:41\", rolling down a second at a time; a date when it's further off. */\nfunction Expiry({ at }: { at: number }) {\n  const [now, setNow] = React.useState<number | null>(null);\n  const left = now === null ? null : at - now;\n  const ticking = left !== null && left > 0 && left < 2 * DAY;\n\n  React.useEffect(() => {\n    if (now === null) {\n      setNow(Date.now());\n      return;\n    }\n    if (!ticking) return;\n    const timer = window.setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000) + 10);\n    return () => window.clearTimeout(timer);\n  }, [now, ticking]);\n\n  if (left === null) return null;\n  if (left <= 0) return <span className=\"text-red-600 dark:text-red-400\">Expired</span>;\n  if (!ticking) return <span>Expires {formatDate(at)}</span>;\n  const s = Math.floor(left / 1000);\n  const two = { minimumIntegerDigits: 2 };\n  return (\n    <span className=\"inline-flex items-baseline whitespace-nowrap tabular-nums text-amber-700 dark:text-amber-400\">\n      <span className=\"sr-only\">Expires {new Date(at).toLocaleString(\"en-GB\", { dateStyle: \"medium\", timeStyle: \"short\" })}</span>\n      <span aria-hidden=\"true\" className=\"inline-flex items-baseline\">\n        Expires in&nbsp;\n        <NumberRoll value={Math.floor(s / 3600)} direction=\"down\" />:\n        <NumberRoll value={Math.floor(s / 60) % 60} format={two} direction=\"down\" />:\n        <NumberRoll value={s % 60} format={two} direction=\"down\" />\n      </span>\n    </span>\n  );\n}\n\nfunction ScopeChip({ scope, on, reduced, onToggle }: { scope: ApiKeyScope; on: boolean; reduced: boolean; onToggle: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={on}\n      onClick={onToggle}\n      className={`inline-flex h-7 cursor-pointer items-center rounded-full px-2.5 text-[12px] font-medium transition-[background-color,color,box-shadow] duration-200 ${FOCUS} ${\n        on ? \"bg-accent text-foreground shadow-[inset_0_0_0_1px_var(--border)]\" : \"text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:text-foreground\"\n      }`}\n    >\n      <span className=\"grid\" style={{ gridTemplateColumns: on ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}` }}>\n        <span className=\"min-w-0 overflow-clip\">\n          <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"mr-1 size-3.5\">\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 ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 360ms ${EASE} ${on ? 120 : 0}ms` }}\n            />\n          </svg>\n        </span>\n      </span>\n      {scope.label}\n    </button>\n  );\n}\n\nfunction DraftFace({ draft, scopes, placeholder, reduced, act }: { draft: Draft; scopes?: ApiKeyScope[]; placeholder: string; reduced: boolean; act: React.RefObject<Actions> }) {\n  const id = React.useId();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const lineRef = React.useRef<HTMLParagraphElement>(null);\n  const busy = draft.status === \"pending\";\n  const line = draft.error ?? \"The secret is shown once, when it's created.\";\n\n  React.useEffect(() => {\n    inputRef.current?.focus({ preventScroll: true });\n  }, []);\n\n  // A new line under the form lifts in; the field shakes when the error is about it.\n  React.useLayoutEffect(() => {\n    if (reduced || !draft.error) return;\n    lineRef.current?.animate([{ opacity: 0, transform: \"translateY(4px)\", filter: \"blur(3px)\" }, { opacity: 1, transform: \"none\", filter: \"blur(0px)\" }], { duration: 320, easing: EASE });\n    if (!draft.name.trim())\n      inputRef.current?.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], { duration: 380, easing: \"ease-out\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [draft.error]);\n\n  return (\n    <form\n      className=\"px-4 py-3.5 @md:px-5\"\n      onSubmit={(e) => {\n        e.preventDefault();\n        act.current.submit();\n      }}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && !busy) {\n          e.preventDefault();\n          act.current.cancel();\n        }\n      }}\n    >\n      <label htmlFor={`${id}-name`} className=\"text-[11.5px] text-muted-foreground\">\n        New key\n      </label>\n      <input\n        ref={inputRef}\n        id={`${id}-name`}\n        value={draft.name}\n        readOnly={busy}\n        onChange={(e) => act.current.setDraft({ name: e.target.value, error: null, status: \"idle\" })}\n        placeholder={placeholder}\n        autoComplete=\"off\"\n        aria-invalid={Boolean(draft.error) || undefined}\n        aria-describedby={`${id}-line`}\n        className={`mt-1 h-9 w-full rounded-[10px] bg-transparent px-3 text-[16px] text-foreground outline-none transition-shadow duration-200 placeholder:text-muted-foreground/70 read-only:opacity-60 [@media(pointer:fine)]:text-[13.5px] ${\n          draft.error ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] focus:shadow-[inset_0_0_0_1px_var(--ring)]\"\n        }`}\n      />\n      {scopes && scopes.length > 0 && (\n        <div role=\"group\" aria-label=\"Scopes\" className=\"mt-2.5 flex flex-wrap gap-1.5\">\n          {scopes.map((s) => {\n            const on = draft.scopes.includes(s.value);\n            return (\n              <ScopeChip\n                key={s.value}\n                scope={s}\n                on={on}\n                reduced={reduced}\n                onToggle={() => act.current.setDraft({ scopes: on ? draft.scopes.filter((v) => v !== s.value) : [...draft.scopes, s.value], error: null, status: \"idle\" })}\n              />\n            );\n          })}\n        </div>\n      )}\n      <div className=\"mt-3 flex flex-wrap items-center justify-end gap-x-3 gap-y-2\">\n        <p ref={lineRef} id={`${id}-line`} className={`mr-auto min-w-0 text-[11.5px] ${draft.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n          {line}\n        </p>\n        <div className=\"flex items-center gap-1\">\n          <button\n            type=\"button\"\n            onClick={() => act.current.cancel()}\n            disabled={busy}\n            className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default disabled:opacity-50 ${FOCUS}`}\n          >\n            Cancel\n          </button>\n          <StatusButton type=\"submit\" size=\"sm\" status={draft.status} labels={{ idle: \"Create key\", pending: \"Creating\", success: \"Created\", error: \"Try again\" }} />\n        </div>\n      </div>\n    </form>\n  );\n}\n\nfunction SecretBlock({ secret, reduced, blockRef }: { secret: string; reduced: boolean; blockRef: React.RefObject<HTMLElement | null> }) {\n  // The secret rises in a character at a time, the whole of it inside half a second.\n  React.useLayoutEffect(() => {\n    const el = blockRef.current;\n    if (!el || reduced) return;\n    const chars = el.querySelectorAll<HTMLElement>(\"[data-ch]\");\n    const step = Math.min(10, 420 / Math.max(1, chars.length));\n    chars.forEach((c, i) =>\n      c.animate([{ opacity: 0, filter: \"blur(4px)\", transform: \"translateY(0.35em)\" }, { opacity: 1, filter: \"blur(0px)\", transform: \"none\" }], {\n        duration: 360,\n        delay: 140 + i * step,\n        easing: EASE,\n        fill: \"backwards\",\n      }),\n    );\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [secret]);\n\n  return (\n    <code\n      ref={blockRef as React.RefObject<HTMLElement>}\n      className=\"block rounded-[10px] bg-muted px-3 py-2.5 font-mono text-[12px] leading-[1.6] break-all text-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n    >\n      <Chars text={secret} />\n    </code>\n  );\n}\n\ninterface RowProps {\n  rk: string;\n  apiKey: ApiKey | null;\n  draft: Draft | null;\n  reveal: Reveal | null;\n  scopes?: ApiKeyScope[];\n  placeholder: string;\n  gone: boolean;\n  canRoll: boolean;\n  canRevoke: boolean;\n  reduced: boolean;\n  act: React.RefObject<Actions>;\n}\n\nconst Row = React.memo(function Row({ rk, apiKey, draft, reveal, scopes, placeholder, gone, canRoll, canRevoke, reduced, act }: RowProps) {\n  const outerRef = React.useRef<HTMLLIElement>(null);\n  const chipRef = React.useRef<HTMLSpanElement>(null);\n  const blockRef = React.useRef<HTMLElement>(null);\n  const height = React.useRef(0);\n  const face = apiKey ? \"key\" : \"draft\";\n  const lastFace = React.useRef(face);\n  const lastDraft = React.useRef(draft);\n  if (draft) lastDraft.current = draft;\n  // The form stays on screen, fading, in the same commit the key takes its place.\n  const [faceSeen, setFaceSeen] = React.useState(face);\n  const [leavingDraft, setLeavingDraft] = React.useState(false);\n  if (faceSeen !== face) {\n    setFaceSeen(face);\n    setLeavingDraft(face === \"key\" && !reduced);\n  }\n  const shown = reveal && !reveal.sealing;\n  // The secret stays in the fold while it closes, then leaves the page.\n  const [kept, setKept] = React.useState<string | null>(reveal?.secret ?? null);\n  if (reveal && reveal.secret !== kept) setKept(reveal.secret);\n  React.useEffect(() => {\n    if (!reveal) setKept(null);\n  }, [reveal]);\n\n  React.useLayoutEffect(() => {\n    const el = outerRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => (height.current = el.offsetHeight));\n    height.current = el.offsetHeight;\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // The form became the key: the row eases from the form's height to the key's while the form fades out over it.\n  React.useLayoutEffect(() => {\n    if (lastFace.current === face) return;\n    lastFace.current = face;\n    const el = outerRef.current;\n    if (!el || reduced || face !== \"key\") return;\n    const to = el.offsetHeight;\n    if (Math.abs(to - height.current) > 0.5) el.animate([{ height: `${height.current}px` }, { height: `${to}px` }], { duration: 460, easing: EASE });\n  }, [face, reduced]);\n\n  React.useEffect(() => {\n    if (!leavingDraft) return;\n    const timer = window.setTimeout(() => setLeavingDraft(false), 320);\n    return () => window.clearTimeout(timer);\n  }, [leavingDraft]);\n\n  // Done: the prefix and last four fly from the secret into the chip; the rest blurs out as the fold closes.\n  const sealing = Boolean(reveal?.sealing);\n  React.useLayoutEffect(() => {\n    if (!sealing || !apiKey || !reveal) return;\n    const chip = chipRef.current;\n    const block = blockRef.current;\n    if (!chip || !block || reduced) return;\n    const from = [...block.querySelectorAll<HTMLElement>(\"[data-ch]\")];\n    const to = [...chip.querySelectorAll<HTMLElement>(\"[data-ch]\")];\n    const text = masked(apiKey);\n    const secret = reveal.secret;\n    const pairs = new Map<number, number>();\n    const prefix = apiKey.prefix ?? \"\";\n    if (prefix && secret.startsWith(prefix)) for (let i = 0; i < prefix.length; i++) pairs.set(i, i);\n    if (secret.endsWith(apiKey.last4)) for (let j = 0; j < apiKey.last4.length; j++) pairs.set(text.length - apiKey.last4.length + j, secret.length - apiKey.last4.length + j);\n    const flown = new Set(pairs.values());\n    to.forEach((c, i) => {\n      const source = pairs.get(i);\n      if (source === undefined || !from[source]) {\n        c.animate([{ opacity: 0, filter: \"blur(4px)\", transform: \"translateY(0.35em)\" }, { opacity: 1, filter: \"blur(0px)\", transform: \"none\" }], { duration: 380, delay: 220, easing: EASE, fill: \"backwards\" });\n        return;\n      }\n      const a = from[source].getBoundingClientRect();\n      const b = c.getBoundingClientRect();\n      // Together, so \"sk_live_\" and the last four travel as words.\n      c.animate([{ transform: `translate(${a.left - b.left}px, ${a.top - b.top}px)` }, { transform: \"none\" }], { duration: 540, easing: EASE, fill: \"backwards\" });\n    });\n    from.forEach((c, i) => {\n      if (flown.has(i)) c.animate([{ opacity: 0 }, { opacity: 0 }], { duration: 600, fill: \"forwards\" });\n      else c.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, filter: \"blur(4px)\", transform: \"translateY(-0.3em)\" }], { duration: 220, easing: EASE, fill: \"forwards\" });\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [sealing]);\n\n  const hide = Boolean(reveal) && !sealing;\n  // While the chip is hidden it keeps the old key's characters, so a roll never flashes the new ones.\n  const [chipText, setChipText] = React.useState(apiKey ? masked(apiKey) : \"\");\n  if (apiKey && !hide && chipText !== masked(apiKey)) setChipText(masked(apiKey));\n\n  const scopeText = apiKey?.scopes?.length ? apiKey.scopes.map((v) => scopes?.find((s) => s.value === v)?.label ?? v).join(\", \") : null;\n  const expires = apiKey?.expiresAt ? toTime(apiKey.expiresAt) : null;\n\n  return (\n    <li ref={outerRef} data-row-id={rk} className=\"relative overflow-clip border-t border-border first:border-t-0\">\n      {apiKey ? (\n        <div className=\"px-4 py-3 transition-opacity duration-300 @md:px-5\" style={{ opacity: gone ? 0.5 : 1 }}>\n          <div className=\"flex min-h-7 items-center gap-2\">\n            <p className=\"min-w-0 flex-1 truncate text-[13.5px] font-medium tracking-[-0.01em]\">{apiKey.name}</p>\n            <span ref={chipRef} className=\"relative inline-flex h-[22px] shrink-0 items-center px-1.5 font-mono text-[11.5px] text-foreground\">\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-0 rounded-md bg-muted shadow-[inset_0_0_0_1px_var(--border)]\"\n                style={{ opacity: hide ? 0 : 1, transition: reduced ? \"none\" : `opacity ${hide ? 200 : 420}ms ${EASE} ${hide ? 0 : 160}ms` }}\n              />\n              <span className=\"sr-only\">{`Key ending ${apiKey.last4}`}</span>\n              <span\n                aria-hidden=\"true\"\n                className=\"relative whitespace-nowrap\"\n                style={{ opacity: hide ? 0 : 1, filter: hide && !reduced ? \"blur(3px)\" : \"none\", transition: hide && !reduced ? `opacity 200ms ${EASE}, filter 200ms ${EASE}` : \"none\" }}\n              >\n                <Chars text={chipText} />\n              </span>\n            </span>\n            {(canRoll || canRevoke) && (\n              <div className=\"-mr-1 flex items-center\">\n                {canRoll && !expires && (\n                  <ConfirmAction\n                    tone=\"default\"\n                    icon={<RotateCw strokeWidth={1.75} />}\n                    label={`Roll ${apiKey.name}`}\n                    labels={{ idle: \"Roll key\", pending: \"Rolling\", success: \"Rolled\", error: \"Try again\" }}\n                    onConfirm={() => act.current.roll(apiKey)}\n                    resetAfter={1400}\n                  />\n                )}\n                {canRevoke && (\n                  <ConfirmAction\n                    icon={<Trash2 strokeWidth={1.75} />}\n                    label={`Revoke ${apiKey.name}`}\n                    labels={{ idle: \"Revoke\", pending: \"Revoking\", success: \"Revoked\", error: \"Try again\" }}\n                    onConfirm={() => act.current.revoke(apiKey)}\n                  />\n                )}\n              </div>\n            )}\n          </div>\n          <p className=\"mt-0.5 flex flex-wrap items-baseline gap-x-1.5 text-[11.5px] text-muted-foreground\">\n            {scopeText && (\n              <>\n                <span>{scopeText}</span>\n                <span aria-hidden=\"true\">·</span>\n              </>\n            )}\n            {apiKey.createdAt != null && (\n              <>\n                <span>Created {formatDate(apiKey.createdAt)}</span>\n                <span aria-hidden=\"true\">·</span>\n              </>\n            )}\n            {expires !== null ? (\n              <Expiry at={expires} />\n            ) : apiKey.lastUsedAt ? (\n              <RelativeTime date={apiKey.lastUsedAt} prefix=\"Last used\" />\n            ) : (\n              <span>Never used</span>\n            )}\n          </p>\n          <Fold open={Boolean(shown)} reduced={reduced}>\n            {kept && (\n              <div className=\"pt-3\">\n                <SecretBlock key={kept} secret={kept} reduced={reduced} blockRef={blockRef} />\n                <div className=\"mt-2.5 flex flex-wrap items-center justify-end gap-x-3 gap-y-2\">\n                  <p className=\"mr-auto text-[11.5px] text-muted-foreground\">Copy it now. You won&rsquo;t see it again.</p>\n                  <div className=\"flex items-center gap-1\">\n                    <button\n                      type=\"button\"\n                      onClick={() => act.current.seal(apiKey.id)}\n                      className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                    >\n                      Done\n                    </button>\n                    <CopyButton secret={kept} autoFocus={Boolean(shown)} />\n                  </div>\n                </div>\n              </div>\n            )}\n          </Fold>\n        </div>\n      ) : null}\n      {(face === \"draft\" || leavingDraft) && lastDraft.current && (\n        <FaceOut leaving={face !== \"draft\"}>\n          <DraftFace draft={draft ?? lastDraft.current} scopes={scopes} placeholder={placeholder} reduced={reduced} act={act} />\n        </FaceOut>\n      )}\n    </li>\n  );\n});\n\n/** The form, fading out through a blur over the key that took its place. */\nfunction FaceOut({ leaving, children }: { leaving: boolean; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    if (!leaving) return;\n    ref.current?.animate([{ opacity: 1, filter: \"blur(0px)\" }, { opacity: 0, filter: \"blur(4px)\" }], { duration: 260, easing: EASE, fill: \"forwards\" });\n  }, [leaving]);\n  return (\n    <div ref={ref} className={leaving ? \"pointer-events-none absolute inset-x-0 top-0\" : \"\"} inert={leaving} aria-hidden={leaving || undefined}>\n      {children}\n    </div>\n  );\n}\n\nfunction CopyButton({ secret, autoFocus }: { secret: string; autoFocus: boolean }) {\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const wrap = React.useRef<HTMLSpanElement>(null);\n  React.useEffect(() => {\n    if (autoFocus) wrap.current?.querySelector(\"button\")?.focus({ preventScroll: true });\n  }, [autoFocus]);\n  return (\n    <span ref={wrap} className=\"contents\">\n      <StatusButton\n        size=\"sm\"\n        status={status}\n        icon={<Copy strokeWidth={1.75} />}\n        labels={{ idle: \"Copy\", pending: \"Copying\", success: \"Copied\", error: \"Try again\" }}\n        onReset={() => setStatus(\"idle\")}\n        onClick={async () => {\n          setStatus(\"pending\");\n          try {\n            await navigator.clipboard.writeText(secret);\n            setStatus(\"success\");\n          } catch {\n            setStatus(\"error\");\n          }\n        }}\n      />\n    </span>\n  );\n}\n\nexport function ApiKeys({\n  value,\n  defaultValue = NONE,\n  onValueChange,\n  onCreate,\n  onRoll,\n  onRevoke,\n  scopes,\n  defaultScopes,\n  title = \"API keys\",\n  description = \"Keys give your code access to this workspace. Keep them out of the browser.\",\n  placeholder = \"e.g. Production server\",\n  className = \"\",\n  ...props\n}: ApiKeysProps) {\n  const reduced = useReducedMotion();\n  const [inner, setInner] = React.useState(defaultValue);\n  const keys = value ?? inner;\n  const [draft, setDraftState] = React.useState<Draft | null>(null);\n  const [reveal, setReveal] = React.useState<Reveal | null>(null);\n  const [alias, setAlias] = React.useState<Record<string, string>>({});\n  const [gone, setGone] = React.useState<string[]>([]);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n  const createRef = React.useRef<HTMLButtonElement>(null);\n  const counter = React.useRef(0);\n  const latest = React.useRef({ keys, draft, alias });\n  latest.current = { keys, draft, alias };\n\n  const commit = (next: ApiKey[]) => {\n    if (value === undefined) setInner(next);\n    onValueChange?.(next);\n  };\n  const rkOf = (id: string, a = alias) => a[id] ?? id;\n\n  const rows: { rk: string; apiKey: ApiKey | null }[] = [...(draft ? [{ rk: draft.rk, apiKey: null }] : []), ...keys.map((k) => ({ rk: rkOf(k.id), apiKey: k }))];\n  const ranks = React.useMemo(() => new Map(rows.map((r, i) => [r.rk, i])), [rows.map((r) => r.rk).join(\"|\")]); // eslint-disable-line react-hooks/exhaustive-deps\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n\n  const act = React.useRef<Actions>(null!);\n  act.current = {\n    setDraft: (patch) => setDraftState((d) => (d ? { ...d, ...patch } : d)),\n    cancel: () => {\n      setDraftState(null);\n      requestAnimationFrame(() => createRef.current?.focus({ preventScroll: true }));\n    },\n    submit: async () => {\n      const d = latest.current.draft;\n      if (!d || d.status === \"pending\") return;\n      const name = d.name.trim();\n      if (!name) return setDraftState({ ...d, error: \"Give the key a name, so you know where it's used.\", status: \"idle\" });\n      if (scopes?.length && !d.scopes.length) return setDraftState({ ...d, error: \"Pick at least one scope.\", status: \"idle\" });\n      setDraftState({ ...d, status: \"pending\", error: null });\n      try {\n        const { key, secret } = await onCreate({ name, scopes: d.scopes });\n        // One commit: the form's row becomes the key's row and opens on its secret.\n        setAlias((a) => ({ ...a, [key.id]: d.rk }));\n        commit([key, ...latest.current.keys]);\n        setDraftState(null);\n        setReveal({ id: key.id, secret, sealing: false });\n        setAnnounce(`${key.name} created. Copy the secret now; it won't be shown again.`);\n      } catch (e) {\n        setDraftState((cur) => cur && { ...cur, status: \"error\", error: message(e, \"The key couldn't be created. Try again.\") });\n      }\n    },\n    seal: (id) => {\n      setReveal((r) => (r && r.id === id ? { ...r, sealing: true } : r));\n      setAnnounce(\"Secret hidden.\");\n      window.setTimeout(() => setReveal((r) => (r && r.id === id && r.sealing ? null : r)), 620);\n      requestAnimationFrame(() => createRef.current?.focus({ preventScroll: true }));\n    },\n    roll: async (key) => {\n      if (!onRoll) return;\n      const { key: next, secret, previous } = await onRoll(key);\n      const { keys: now, alias: a } = latest.current;\n      const rk = rkOf(key.id, a);\n      setAlias((cur) => ({ ...cur, [next.id]: rk, ...(previous ? { [previous.id]: `${rk}~${++counter.current}` } : {}) }));\n      commit(now.flatMap((k) => (k.id === key.id ? [next, ...(previous ? [previous] : [])] : [k])));\n      setReveal({ id: next.id, secret, sealing: false });\n      setAnnounce(`${next.name} rolled. Copy the new secret now; it won't be shown again.`);\n    },\n    revoke: async (key) => {\n      await onRevoke?.(key);\n      setGone((g) => [...g, key.id]);\n      setAnnounce(`${key.name} revoked.`);\n      // Long enough for the check to draw; then the row folds away.\n      window.setTimeout(() => {\n        commit(latest.current.keys.filter((k) => k.id !== key.id));\n        setGone((g) => g.filter((id) => id !== key.id));\n        setReveal((r) => (r?.id === key.id ? null : r));\n      }, 700);\n    },\n  };\n\n  const openDraft = () => {\n    if (draft) return;\n    setDraftState({ rk: `draft-${++counter.current}`, name: \"\", scopes: defaultScopes ?? scopes?.map((s) => s.value) ?? [], status: \"idle\", error: null });\n  };\n\n  return (\n    <div className={`@container 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={keys.length} />\n            </span>\n          </p>\n          <p className=\"mt-0.5 text-[12px] text-muted-foreground\">{description}</p>\n        </div>\n        <button\n          ref={createRef}\n          type=\"button\"\n          onClick={openDraft}\n          aria-disabled={Boolean(draft) || undefined}\n          className={`inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-3 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-[background-color,opacity] duration-200 hover:bg-accent aria-disabled:cursor-default aria-disabled:opacity-40 aria-disabled:hover:bg-transparent ${FOCUS}`}\n        >\n          <Plus className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n          Create key\n        </button>\n      </div>\n\n      <div className=\"relative border-t border-border\">\n        <ul ref={listRef} aria-label={title} className=\"relative\">\n          {rows.map((r) => (\n            <Row\n              key={r.rk}\n              rk={r.rk}\n              apiKey={r.apiKey}\n              draft={r.apiKey ? null : draft}\n              reveal={r.apiKey && reveal?.id === r.apiKey.id ? reveal : null}\n              scopes={scopes}\n              placeholder={placeholder}\n              gone={Boolean(r.apiKey && gone.includes(r.apiKey.id))}\n              canRoll={Boolean(onRoll)}\n              canRevoke={Boolean(onRevoke)}\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        <Fold open={!rows.length} reduced={reduced}>\n          <p className=\"px-4 py-6 text-center text-[12.5px] text-muted-foreground @md:px-5\">No keys yet. Create one to call the API from your server.</p>\n        </Fold>\n      </div>\n\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/confirm-action.tsx","type":"registry:component","content":"// Hairline UI · api-keys · 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 · api-keys · 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 · api-keys · 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/api-keys. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}