{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"approval-card","type":"registry:component","title":"Approval card","description":"The agent asks before it acts: what it wants to do, the details, Approve or Deny.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/number-roll.json","https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/approval-card.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Ban, Clock, ShieldQuestion } from \"lucide-react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * APPROVAL CARD: the agent asks before it acts\n *\n *   pending   what it wants to do, with Approve and Deny; an\n *             optional countdown rolls down second by second\n *   approved  the card folds into one line: the body closes,\n *             \"Needs your approval\" morphs to \"Approved\" and a\n *             check draws itself where the shield was\n *   denied    folds the same way into \"Denied\"\n *   expired   the countdown reaches 0:00 and it folds into\n *             \"Expired\"\n *\n * One surface throughout: it changes shape instead of being\n * replaced. Works controlled (pass status) or on its own (it\n * remembers the choice). Mark destructive actions so Approve\n * reads as such.\n * ───────────────────────────────────────────────────────── */\n\nexport type ApprovalStatus = \"pending\" | \"approved\" | \"denied\" | \"expired\";\n\nexport interface ApprovalDetail {\n  label: string;\n  value: React.ReactNode;\n}\n\nexport interface ApprovalCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  /** What the agent wants to do, e.g. \"Send email to 3 people\". */\n  title: string;\n  description?: string;\n  /** The specifics worth checking before approving. */\n  details?: ApprovalDetail[];\n  /** Why approval is needed, e.g. AI SDK's approval.requestReason. */\n  reason?: string;\n  status?: ApprovalStatus;\n  defaultStatus?: ApprovalStatus;\n  onApprove?: () => void;\n  onDeny?: () => void;\n  approveLabel?: string;\n  denyLabel?: string;\n  /** Styles Approve as a destructive action. */\n  destructive?: boolean;\n  /** When the request lapses (ms). Shows a countdown, then expires. */\n  expiresAt?: number;\n  onExpire?: () => void;\n}\n\nconst MORPH = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst LABELS: Record<ApprovalStatus, string> = { pending: \"Needs your approval\", approved: \"Approved\", denied: \"Denied\", expired: \"Expired\" };\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\n/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */\nconst swap = (on: boolean, reduced: boolean): React.CSSProperties => ({\n  opacity: on ? 1 : 0,\n  transform: on ? \"none\" : \"scale(0.6)\",\n  filter: on ? \"none\" : \"blur(3px)\",\n  transition: reduced ? \"none\" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,\n});\n\nfunction DrawnCheck({ drawn, reduced }: { drawn: boolean; reduced: boolean }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: drawn && !reduced ? `stroke-dashoffset 420ms ${MORPH} 160ms` : \"none\" }}\n      />\n    </svg>\n  );\n}\n\nexport function ApprovalCard({\n  title,\n  description,\n  details,\n  reason,\n  status,\n  defaultStatus = \"pending\",\n  onApprove,\n  onDeny,\n  approveLabel = \"Approve\",\n  denyLabel = \"Deny\",\n  destructive = false,\n  expiresAt,\n  onExpire,\n  className = \"\",\n  ...props\n}: ApprovalCardProps) {\n  const reduced = useReducedMotion();\n  const [ownStatus, setOwnStatus] = React.useState<ApprovalStatus>(defaultStatus);\n  const current = status ?? ownStatus;\n  const decided = current !== \"pending\";\n  const [now, setNow] = React.useState(expiresAt ?? 0);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n\n  // Count down while pending; expire on time.\n  React.useEffect(() => {\n    if (current !== \"pending\" || expiresAt === undefined) return;\n    const tick = () => {\n      const t = Date.now();\n      setNow(t);\n      if (t >= expiresAt) {\n        if (status === undefined) setOwnStatus(\"expired\");\n        onExpire?.();\n      }\n    };\n    tick();\n    const timer = window.setInterval(tick, 1000);\n    return () => window.clearInterval(timer);\n  }, [current, expiresAt, status, onExpire]);\n\n  const decide = (next: \"approved\" | \"denied\") => {\n    // The buttons are about to fold away: keep focus on the card instead of dropping it on the page.\n    if (cardRef.current?.contains(document.activeElement)) cardRef.current.focus({ preventScroll: true });\n    if (status === undefined) setOwnStatus(next);\n    if (next === \"approved\") onApprove?.();\n    else onDeny?.();\n  };\n\n  const left = expiresAt === undefined ? 0 : Math.max(0, Math.ceil((expiresAt - now) / 1000));\n  const icons: Record<ApprovalStatus, React.ReactNode> = {\n    pending: <ShieldQuestion className=\"size-3.5\" />,\n    approved: (\n      <span className=\"text-emerald-600 dark:text-emerald-400\">\n        <DrawnCheck drawn={current === \"approved\"} reduced={reduced} />\n      </span>\n    ),\n    denied: <Ban className=\"size-3.5\" />,\n    expired: <Clock className=\"size-3.5\" />,\n  };\n  const fold = (open: boolean): React.CSSProperties => ({\n    gridTemplateRows: open ? \"1fr\" : \"0fr\",\n    transition: reduced ? \"none\" : `grid-template-rows 480ms ${MORPH}`,\n  });\n  const fade = (on: boolean): React.CSSProperties => ({\n    opacity: on ? 1 : 0,\n    filter: on ? \"none\" : \"blur(4px)\",\n    transition: reduced ? \"none\" : on ? `opacity 380ms ${MORPH} 120ms, filter 380ms ${MORPH} 120ms` : `opacity 160ms ease-out, filter 160ms ease-out`,\n  });\n\n  return (\n    <div\n      ref={cardRef}\n      tabIndex={-1}\n      role=\"group\"\n      aria-label={decided ? `${LABELS[current]}: ${title}` : `Approval needed: ${title}`}\n      className={`rounded-xl border border-border bg-background outline-none animate-[ui-fade-up_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none ${className}`}\n      style={{\n        padding: decided ? \"10px 12px\" : 16,\n        transition: reduced ? \"none\" : `padding 480ms ${MORPH}`,\n      }}\n      {...props}\n    >\n      {/* The header is also the folded line: it keeps its place while everything under it closes. */}\n      <div className=\"flex min-w-0 items-center gap-2 text-[12.5px] font-medium\">\n        <span aria-hidden=\"true\" className={`relative flex size-3.5 shrink-0 items-center justify-center transition-colors duration-300 ${decided ? \"text-foreground/70\" : \"text-muted-foreground\"}`}>\n          {(Object.keys(icons) as ApprovalStatus[]).map((s) => (\n            <span key={s} className=\"absolute inset-0 flex items-center justify-center\" style={swap(s === current, reduced)}>\n              {icons[s]}\n            </span>\n          ))}\n        </span>\n        <span className={`shrink-0 transition-colors duration-300 ${decided ? \"text-foreground\" : \"text-muted-foreground\"}`}>\n          <TextMorph>{LABELS[current]}</TextMorph>\n        </span>\n        {/* The action slides into the line as the card folds. */}\n        <span\n          aria-hidden={!decided || undefined}\n          className=\"grid min-w-0 flex-1\"\n          style={{\n            gridTemplateColumns: decided ? \"1fr\" : \"0fr\",\n            ...fade(decided),\n            transition: reduced ? \"none\" : `grid-template-columns 480ms ${MORPH}, ${decided ? `opacity 380ms ${MORPH} 160ms, filter 380ms ${MORPH} 160ms` : \"opacity 160ms ease-out, filter 160ms ease-out\"}`,\n          }}\n        >\n          <span className=\"min-w-0 truncate font-normal text-muted-foreground\">{title}</span>\n        </span>\n        {expiresAt !== undefined && (\n          <span\n            aria-hidden={decided || undefined}\n            className=\"ml-auto grid shrink-0\"\n            style={{ gridTemplateColumns: decided ? \"0fr\" : \"1fr\", ...fade(!decided), transition: reduced ? \"none\" : `grid-template-columns 480ms ${MORPH}, opacity 160ms ease-out, filter 160ms ease-out` }}\n          >\n            <span className=\"flex min-w-0 overflow-hidden font-mono font-normal tabular-nums text-muted-foreground\">\n              <span className=\"sr-only\">Time left: </span>\n              <NumberRoll value={Math.floor(left / 60)} duration={600} />\n              <span aria-hidden=\"true\">:</span>\n              <NumberRoll value={left % 60} format={{ minimumIntegerDigits: 2 }} direction=\"down\" duration={600} />\n            </span>\n          </span>\n        )}\n      </div>\n\n      {/* Everything else folds shut once it's answered. */}\n      <div inert={decided} aria-hidden={decided || undefined} className=\"grid\" style={fold(!decided)}>\n        <div className=\"min-h-0 overflow-hidden\">\n          <div style={fade(!decided)}>\n            <p className=\"pt-2 text-[14px] font-medium leading-snug text-foreground\">{title}</p>\n            {description && <p className=\"mt-1 text-[13px] leading-relaxed text-muted-foreground\">{description}</p>}\n\n            {details && details.length > 0 && (\n              <dl className=\"mt-3 grid grid-cols-[minmax(0,auto)_1fr] gap-x-4 gap-y-1.5 rounded-lg bg-muted px-3 py-2.5 text-[12.5px]\">\n                {details.map((d) => (\n                  <React.Fragment key={d.label}>\n                    <dt className=\"text-muted-foreground\">{d.label}</dt>\n                    <dd className=\"min-w-0 break-words text-foreground\">{d.value}</dd>\n                  </React.Fragment>\n                ))}\n              </dl>\n            )}\n\n            {reason && <p className=\"mt-2.5 text-[12px] leading-relaxed text-muted-foreground\">{reason}</p>}\n\n            <div className=\"mt-4 flex justify-end gap-2\">\n              <button\n                type=\"button\"\n                onClick={() => decide(\"denied\")}\n                className={`h-8 rounded-lg border border-border px-3 text-[12.5px] font-medium text-foreground transition-colors hover:bg-accent ${FOCUS}`}\n              >\n                {denyLabel}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => decide(\"approved\")}\n                className={`h-8 rounded-lg px-3.5 text-[12.5px] font-medium transition-[background-color,transform] active:scale-[0.98] ${FOCUS} ${\n                  destructive ? \"bg-red-600 text-white hover:bg-red-700\" : \"bg-primary text-primary-foreground hover:bg-primary/90\"\n                }`}\n              >\n                {approveLabel}\n              </button>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* Announces the answer; outside the buttons so their names stay their labels. */}\n      <span role=\"status\" className=\"sr-only\">\n        {decided ? `${LABELS[current]}: ${title}` : \"\"}\n      </span>\n    </div>\n  );\n}\n"}],"css":{"@keyframes ui-fade-up":{"from":{"opacity":"0","transform":"translateY(4px)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/approval-card"}