{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wallet","type":"registry:block","title":"Wallet","description":"A balance you can send from: the balance panel turns into the transfer, the button goes Sending → Sent, and the money visibly leaves.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/copy-button.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/wallet.tsx","type":"registry:component","content":"// Hairline UI · wallet · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowDown, ArrowUp, Delete, Eye, EyeOff, X } from \"lucide-react\";\nimport { CopyButton } from \"./copy-button\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { Faces } from \"./faces\";\nimport { useRowMotion } from \"./use-row-motion\";\nimport { INTERACTION_FOCUS, INTERACTION_PRESS, interactionIcon, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * WALLET: a balance you can send from, in one surface\n *\n *   card      the balance sits on a card-shaped panel in your\n *             --primary, lit from its top corner and engraved\n *             with fine hairlines like a banknote; the figure\n *             rolls the way it changed, and hiding it folds the\n *             digits into dots\n *   send      the card itself turns into the transfer: the figure\n *             gives way to the amount you type (each new place\n *             rolls open), the actions and activity fold away and\n *             a keypad folds in, all in one surface\n *   sent      the button goes Sending → Sent with a drawn check,\n *             the card turns back, the balance rolls down by the\n *             amount and the new row slides into Activity\n *   request   the same surface asks instead; a request shows as a\n *             pending row until your server marks it paid, then\n *             \"Requested\" morphs into \"Paid\" and the balance rolls up\n *\n * The money is yours, not the card's: pass balance and\n * transactions, and do the transfer in onSend / onRequest. Resolve\n * and the card shows it sent; throw and it keeps the amount and\n * says why.\n * ───────────────────────────────────────────────────────── */\n\nexport interface WalletContact {\n  id: string;\n  name: string;\n  /** Photo URL; initials on a tint from your chart colours when missing. */\n  src?: string;\n}\n\nexport interface WalletTransaction {\n  id: string;\n  title: string;\n  date: Date | string | number;\n  /** Signed, in major units: negative is money out, positive is money in. */\n  amount: number;\n  /** A request that hasn't been paid yet shows as pending. */\n  status?: \"pending\" | \"paid\";\n  /** Photo URL for the person; initials when missing. */\n  src?: string;\n  /** A merchant's logo or glyph, shown on a tinted tile instead of initials. */\n  icon?: React.ReactNode;\n}\n\nexport interface WalletTransfer {\n  contact: WalletContact;\n  /** In major units, always positive. */\n  amount: number;\n  note: string;\n}\n\nexport interface WalletProps {\n  /** The account holder, on the card. */\n  name?: string;\n  /** What the account is called, at the top of the card. */\n  label?: string;\n  /** The last four digits of the card or account, shown masked. */\n  last4?: string;\n  balance: number;\n  contacts?: WalletContact[];\n  /** Newest first. */\n  transactions?: WalletTransaction[];\n  /** Move the money; resolve when your server has it, throw an Error with a message to say why it failed. */\n  onSend?: (transfer: WalletTransfer) => Promise<unknown>;\n  /** Ask someone for money; resolve once the request exists (add it to transactions as pending). */\n  onRequest?: (transfer: WalletTransfer) => Promise<unknown>;\n  /** ISO 4217 code. */\n  currency?: string;\n  locale?: string;\n  /** Shown with a copy button while requesting. */\n  paymentLink?: string;\n  /** Hide the balance (controlled). */\n  hidden?: boolean;\n  defaultHidden?: boolean;\n  onHiddenChange?: (hidden: boolean) => void;\n  /** Rows shown before \"See all\". */\n  visibleTransactions?: number;\n  className?: string;\n}\n\ntype Mode = \"home\" | \"send\" | \"request\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst MORPH = 460;\nconst MAX_DIGITS = 9;\nconst NO_CONTACTS: WalletContact[] = [];\nconst NO_TRANSACTIONS: WalletTransaction[] = [];\nconst KEYS = [\"1\", \"2\", \"3\", \"4\", \"5\", \"6\", \"7\", \"8\", \"9\", \".\", \"0\", \"back\"] as const;\n\n/* Light is drawn from the theme's own colours: a lit corner of primary-foreground over --primary, a deeper\n * edge mixed toward black, and 1px edges (a highlight along the top, a faint ring all round). */\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst PANEL: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 110% at 0% 0%, ${mix(\"var(--primary-foreground)\", 24)} 0%, transparent 52%), linear-gradient(165deg, transparent 35%, ${mix(\"black\", 26)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 38)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\nconst RAISED: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 93, \"var(--foreground)\")})`,\n  boxShadow: `inset 0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 90)}`,\n};\nconst LIT: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, ${mix(\"var(--primary-foreground)\", 16)}, transparent 60%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 32)}, inset 0 0 0 1px ${mix(\"black\", 10)}`,\n};\n\n/** A stable tint from your chart colours, so the same person always has the same colour. */\nfunction tint(seed: string): React.CSSProperties {\n  let h = 0;\n  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`;\n  return { background: mix(chart, 20, \"var(--card)\"), color: mix(chart, 72, \"var(--foreground)\"), boxShadow: `inset 0 0 0 1px ${mix(chart, 26)}` };\n}\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase())\n    .join(\"\");\n\n/** A person (photo or initials) or a merchant (its glyph on a rounded tile), tinted the same way everywhere. */\nfunction Face({ name, src, icon, size }: { name: string; src?: string; icon?: React.ReactNode; size: number }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={`relative flex shrink-0 select-none items-center justify-center overflow-hidden font-medium ${icon ? \"rounded-[12px]\" : \"rounded-full\"}`}\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.36), ...tint(name) }}\n    >\n      {icon ? <span className=\"flex [&_svg]:size-[46%] size-full items-center justify-center\">{icon}</span> : initials(name)}\n      {src && (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={src} alt=\"\" decoding=\"async\" className=\"absolute inset-0 size-full object-cover\" />\n      )}\n    </span>\n  );\n}\n\n/** Fine hairlines engraved into the card, rippling out from its top corner. */\nfunction Engraving() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 320 200\" preserveAspectRatio=\"xMaxYMin slice\" className=\"pointer-events-none absolute inset-0 size-full text-primary-foreground\" fill=\"none\" stroke=\"currentColor\">\n      {Array.from({ length: 16 }, (_, i) => (\n        <ellipse key={i} cx=\"316\" cy=\"-6\" rx={34 + i * 15} ry={20 + i * 9.5} transform=\"rotate(-16 316 -6)\" strokeWidth=\"0.75\" strokeOpacity={i % 4 === 0 ? 0.13 : 0.07} />\n      ))}\n    </svg>\n  );\n}\n\n/** The card's two faces in one place: the balance and the transfer, sliding past each other. */\nfunction CardFaces({ composing, reduced, ready, children }: { composing: boolean; reduced: boolean; ready: boolean; children: [React.ReactNode, React.ReactNode] }) {\n  const face = (on: boolean, side: number): React.CSSProperties => ({\n    opacity: on ? 1 : 0,\n    transform: on || reduced ? \"none\" : `translateX(${side * 18}px)`,\n    filter: on || reduced ? \"none\" : \"blur(4px)\",\n    transition: !ready\n      ? \"none\"\n      : reduced\n        ? \"opacity 160ms ease-out\"\n        : on\n          ? `opacity 320ms ${EASE} 90ms, transform ${MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n          : `opacity 140ms ease-out, transform ${MORPH}ms ${EASE}, filter 200ms ease-out`,\n  });\n  return (\n    <>\n      <div inert={composing || undefined} aria-hidden={composing || undefined} className={`absolute inset-0 flex flex-col ${composing ? \"pointer-events-none\" : \"\"}`} style={face(!composing, -1)}>\n        {children[0]}\n      </div>\n      <div inert={!composing || undefined} aria-hidden={!composing || undefined} className={`absolute inset-0 flex flex-col ${composing ? \"\" : \"pointer-events-none\"}`} style={face(composing, 1)}>\n        {children[1]}\n      </div>\n    </>\n  );\n}\n\n/** A typed amount kept as a string (\"12.5\"), so keys never meet floating point. */\nfunction press(raw: string, key: string) {\n  if (key === \"back\") return raw.slice(0, -1);\n  if (key === \".\") return raw.includes(\".\") ? raw : `${raw || \"0\"}.`;\n  if (raw.includes(\".\")) return raw.split(\".\")[1].length >= 2 ? raw : raw + key;\n  if (raw === \"0\") return key;\n  return raw.length >= MAX_DIGITS ? raw : raw + key;\n}\n\nfunction useMoney(locale: string | undefined, currency: string) {\n  return React.useMemo(() => {\n    const full = new Intl.NumberFormat(locale, { style: \"currency\", currency });\n    const signed = new Intl.NumberFormat(locale, { style: \"currency\", currency, signDisplay: \"exceptZero\" });\n    const decimal = new Intl.NumberFormat(locale).formatToParts(1.5).find((p) => p.type === \"decimal\")?.value ?? \".\";\n    const day = new Intl.DateTimeFormat(locale, { month: \"short\", day: \"numeric\" });\n    const time = new Intl.DateTimeFormat(locale, { hour: \"2-digit\", minute: \"2-digit\" });\n    return {\n      format: (n: number) => full.format(n),\n      signed: (n: number) => signed.format(n).replace(\"-\", \"−\"),\n      decimal,\n      when: (d: Date | string | number) => {\n        const date = new Date(d);\n        return `${day.format(date)} · ${time.format(date)}`;\n      },\n    };\n  }, [locale, currency]);\n}\n\nexport function Wallet({\n  name = \"You\",\n  label: accountLabel = \"Balance\",\n  last4,\n  balance,\n  contacts = NO_CONTACTS,\n  transactions = NO_TRANSACTIONS,\n  onSend,\n  onRequest,\n  currency = \"USD\",\n  locale,\n  paymentLink,\n  hidden: hiddenProp,\n  defaultHidden = false,\n  onHiddenChange,\n  visibleTransactions = 2,\n  className = \"\",\n}: WalletProps) {\n  const reduced = useInteractionReducedMotion();\n  const money = useMoney(locale, currency);\n  const baseId = React.useId();\n\n  const [mode, setMode] = React.useState<Mode>(\"home\");\n  const [contactId, setContactId] = React.useState<string | null>(null);\n  const [raw, setRaw] = React.useState(\"\");\n  const [note, setNote] = React.useState(\"\");\n  const [status, setStatus] = React.useState<ActionStatus>(\"idle\");\n  const [error, setError] = React.useState<string | null>(null);\n  const [said, setSaid] = React.useState(\"\");\n  const [expanded, setExpanded] = React.useState(false);\n  const [ownHidden, setOwnHidden] = React.useState(defaultHidden);\n  const hidden = hiddenProp ?? ownHidden;\n\n  // What the card shows lags the props while a transfer is on screen, so the balance rolls and the new row\n  // arrives where you can see them: once the card has turned back.\n  const [shown, setShown] = React.useState({ balance, transactions });\n  const [ready, setReady] = React.useState(false);\n  const returning = React.useRef(false);\n\n  const composeRef = React.useRef<HTMLDivElement>(null);\n  const sendRef = React.useRef<HTMLButtonElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  React.useEffect(() => {\n    if (mode !== \"home\") return;\n    const delay = returning.current && !reduced ? MORPH * 0.6 : 0;\n    returning.current = false;\n    const timer = window.setTimeout(() => setShown((p) => (p.balance === balance && p.transactions === transactions ? p : { balance, transactions })), delay);\n    return () => window.clearTimeout(timer);\n  }, [mode, balance, transactions, reduced]);\n\n  // Focus follows the surface: into the transfer when it opens, back to Send when it closes.\n  React.useEffect(() => {\n    if (!ready || mode === \"home\") return;\n    composeRef.current?.focus({ preventScroll: true });\n  }, [mode, ready]);\n\n  const composing = mode !== \"home\";\n  const sending = mode === \"send\";\n  const contact = contacts.find((c) => c.id === contactId) ?? null;\n  const first = contact?.name.split(\" \")[0] ?? \"\";\n  const amount = Number(raw || \"0\");\n  const cents = Math.round(amount * 100);\n  const over = sending && cents > Math.round(shown.balance * 100);\n  const valid = Boolean(contact) && cents > 0 && !over;\n  const busy = status === \"pending\" || status === \"success\";\n  const decimals = raw.includes(\".\") ? raw.split(\".\")[1].length : 0;\n\n  const rows = expanded ? shown.transactions : shown.transactions.slice(0, visibleTransactions);\n  const ranks = React.useMemo(() => new Map(shown.transactions.map((t, i) => [t.id, i])), [shown.transactions]);\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n\n  const open = (next: Mode, id: string | null = null) => {\n    setMode(next);\n    setContactId(id);\n    setRaw(\"\");\n    setNote(\"\");\n    setStatus(\"idle\");\n    setError(null);\n    setSaid(\"\");\n  };\n\n  const close = (focusSend = true) => {\n    if (status === \"pending\") return;\n    returning.current = true;\n    setMode(\"home\");\n    setStatus(\"idle\");\n    setError(null);\n    if (focusSend) window.setTimeout(() => sendRef.current?.focus({ preventScroll: true }), reduced ? 0 : MORPH);\n  };\n\n  const key = (k: string) => {\n    if (busy) return;\n    setError(null);\n    if (status === \"error\") setStatus(\"idle\");\n    setRaw((r) => press(r, k));\n  };\n\n  const submit = async () => {\n    if (!valid || !contact || busy) return;\n    const transfer = { contact, amount: cents / 100, note: note.trim() };\n    const run = sending ? onSend : onRequest;\n    setStatus(\"pending\");\n    setError(null);\n    try {\n      await run?.(transfer);\n      setStatus(\"success\");\n      setSaid(sending ? `Sent ${money.format(transfer.amount)} to ${contact.name}` : `Asked ${contact.name} for ${money.format(transfer.amount)}`);\n      window.setTimeout(() => close(), reduced ? 900 : 1100);\n    } catch (e) {\n      setStatus(\"error\");\n      setError(e instanceof Error && e.message ? e.message : \"That didn't go through. Try again.\");\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (!composing) return;\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      close();\n      return;\n    }\n    if ((e.target as HTMLElement).tagName === \"INPUT\") {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        void submit();\n      }\n      return;\n    }\n    if (e.metaKey || e.ctrlKey || e.altKey) return;\n    if (/^[0-9]$/.test(e.key) || e.key === \".\" || e.key === \",\") {\n      e.preventDefault();\n      key(e.key === \",\" ? \".\" : e.key);\n    } else if (e.key === \"Backspace\") {\n      e.preventDefault();\n      key(\"back\");\n    } else if (e.key === \"Enter\" && (e.target as HTMLElement).tagName !== \"BUTTON\") {\n      e.preventDefault();\n      void submit();\n    }\n  };\n\n  const action = !contact\n    ? sending\n      ? \"Choose who to pay\"\n      : \"Choose who to ask\"\n    : cents === 0\n      ? \"Enter an amount\"\n      : over\n        ? \"More than your balance\"\n        : `${sending ? \"Send\" : \"Request\"} ${money.format(cents / 100)}`;\n\n  const sub = error ?? (over ? \"That’s more than you have.\" : sending ? `Available ${hidden ? \"••••\" : money.format(shown.balance)}` : contact ? `${first} gets a request to pay you` : \"They’ll get a request to pay you\");\n\n  const onCard = `cursor-pointer ${INTERACTION_PRESS} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-foreground/70`;\n  const big = `inline-flex h-12 flex-1 cursor-pointer items-center justify-center gap-2 rounded-full text-[15px] font-medium ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`;\n  const figure = \"text-[clamp(38px,12.5cqw,52px)] font-medium leading-none tracking-[-0.045em] tabular-nums\";\n\n  return (\n    <section aria-label=\"Wallet\" onKeyDown={onKeyDown} className={`@container w-full max-w-[400px] [contain-intrinsic-inline-size:400px] rounded-[32px] p-2.5 text-card-foreground ${className}`} style={CARD}>\n      {/* The one colour surface: a card that turns into the transfer. */}\n      <div className=\"relative aspect-[1.6/1] overflow-clip rounded-[24px] text-primary-foreground\" style={PANEL}>\n        <Engraving />\n        <CardFaces composing={composing} reduced={reduced} ready={ready}>\n          <div className=\"flex h-full flex-col justify-between px-5 pb-[18px] pt-4\">\n            <div className=\"flex items-center justify-between gap-3\">\n              <span className=\"truncate text-[13.5px] font-medium tracking-[-0.01em] text-primary-foreground/90\">{accountLabel}</span>\n              <button\n                type=\"button\"\n                aria-label={hidden ? \"Show balance\" : \"Hide balance\"}\n                aria-pressed={hidden}\n                onClick={() => {\n                  if (hiddenProp === undefined) setOwnHidden(!hidden);\n                  onHiddenChange?.(!hidden);\n                }}\n                className={`-mr-1.5 relative flex size-8 items-center justify-center rounded-full text-primary-foreground/80 hover:bg-primary-foreground/10 hover:text-primary-foreground ${onCard}`}\n              >\n                <Eye aria-hidden=\"true\" className=\"absolute size-[17px]\" style={interactionIcon(!hidden, reduced)} />\n                <EyeOff aria-hidden=\"true\" className=\"absolute size-[17px]\" style={interactionIcon(hidden, reduced)} />\n              </button>\n            </div>\n\n            <div>\n              <p className=\"text-[12.5px] text-primary-foreground/65\">Available</p>\n              <p className={`mt-1.5 grid ${figure}`}>\n                <span className=\"sr-only\">{hidden ? \"Balance hidden\" : money.format(shown.balance)}</span>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"[grid-area:1/1]\"\n                  style={{ opacity: hidden ? 0 : 1, filter: hidden && !reduced ? \"blur(6px)\" : \"none\", transition: reduced ? \"opacity 160ms\" : `opacity 260ms ${EASE}, filter 260ms ${EASE}` }}\n                >\n                  <NumberRoll value={shown.balance} locales={locale} format={{ style: \"currency\", currency }} duration={1100} />\n                </span>\n                <span\n                  aria-hidden=\"true\"\n                  className=\"self-center text-[0.6em] tracking-[0.18em] [grid-area:1/1]\"\n                  style={{ opacity: hidden ? 1 : 0, filter: hidden || reduced ? \"none\" : \"blur(6px)\", transition: reduced ? \"opacity 160ms\" : `opacity 260ms ${EASE} 80ms, filter 260ms ${EASE} 80ms` }}\n                >\n                  ••••••\n                </span>\n              </p>\n            </div>\n\n            <div className=\"flex items-center justify-between gap-3 text-[13px] text-primary-foreground/80\">\n              <span className=\"truncate font-medium\">{name}</span>\n              {last4 && <span className=\"shrink-0 font-mono text-[12.5px] tracking-[0.14em] text-primary-foreground/70\">•••• {last4}</span>}\n            </div>\n          </div>\n\n          <div ref={composeRef} tabIndex={-1} role=\"group\" aria-labelledby={`${baseId}-title`} className=\"flex h-full flex-col justify-between px-5 pb-[18px] pt-4 outline-none\">\n            <div className=\"flex items-center justify-between gap-3\">\n              <h3 id={`${baseId}-title`} className=\"truncate text-[13.5px] font-medium tracking-[-0.01em] text-primary-foreground/90\">\n                <TextMorph>{`${sending ? \"Send\" : \"Request\"}${contact ? (sending ? ` to ${first}` : ` from ${first}`) : \"\"}`}</TextMorph>\n              </h3>\n              <button type=\"button\" aria-label=\"Cancel\" onClick={() => close()} className={`-mr-1.5 flex size-8 items-center justify-center rounded-full text-primary-foreground/80 hover:bg-primary-foreground/10 hover:text-primary-foreground ${onCard}`}>\n                <X aria-hidden=\"true\" className=\"size-[17px]\" />\n              </button>\n            </div>\n            <div>\n              <p className=\"text-[12.5px] text-primary-foreground/65\">{sending ? \"Amount\" : \"Asking for\"}</p>\n              <p className={`mt-1.5 ${figure} ${raw ? \"\" : \"text-primary-foreground/45\"}`} style={{ transition: \"color 200ms\" }}>\n                <span className=\"sr-only\">{`Amount ${money.format(amount)}`}</span>\n                <span aria-hidden=\"true\">\n                  <NumberRoll value={amount} locales={locale} format={{ style: \"currency\", currency, minimumFractionDigits: decimals, maximumFractionDigits: 2 }} suffix={raw.endsWith(\".\") ? money.decimal : \"\"} duration={520} />\n                </span>\n              </p>\n            </div>\n            <p role={error || over ? \"alert\" : undefined} className=\"truncate text-[13px] text-primary-foreground/80\">\n              {sub}\n            </p>\n          </div>\n        </CardFaces>\n      </div>\n\n      {/* Request and Send, folded away while the card is the transfer. */}\n      <div\n        inert={composing || undefined}\n        className=\"grid\"\n        style={{ gridTemplateRows: composing ? \"0fr\" : \"1fr\", opacity: composing ? 0 : 1, transition: reduced ? \"opacity 160ms\" : `grid-template-rows ${MORPH}ms ${EASE}, opacity ${composing ? 140 : 320}ms ${EASE}` }}\n      >\n        <div className=\"min-h-0 overflow-clip\">\n          <div className=\"flex gap-2 px-0.5 pt-2.5\">\n            <button type=\"button\" onClick={() => open(\"request\")} className={`${big} text-foreground`} style={RAISED}>\n              <ArrowDown aria-hidden=\"true\" className=\"size-[18px]\" />\n              Request\n            </button>\n            <button ref={sendRef} type=\"button\" onClick={() => open(\"send\")} className={`${big} bg-primary text-primary-foreground`} style={LIT}>\n              <ArrowUp aria-hidden=\"true\" className=\"size-[18px]\" />\n              Send\n            </button>\n          </div>\n        </div>\n      </div>\n\n      {contacts.length > 0 && (\n        <div className=\"px-3 pt-5\">\n          <p className=\"text-[12.5px] text-muted-foreground\">\n            <TextMorph>{composing ? (sending ? \"To\" : \"From\") : \"Send again\"}</TextMorph>\n          </p>\n          <div role={composing ? \"radiogroup\" : \"group\"} aria-label={composing ? (sending ? \"Who to pay\" : \"Who to ask\") : \"Send again\"} className=\"-mx-1 mt-2.5 flex gap-1 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n            {contacts.map((c) => {\n              const on = composing && c.id === contactId;\n              return (\n                <button\n                  key={c.id}\n                  type=\"button\"\n                  role={composing ? \"radio\" : undefined}\n                  aria-checked={composing ? on : undefined}\n                  aria-label={composing ? c.name : `Send to ${c.name}`}\n                  onClick={() => {\n                    if (!composing) return open(\"send\", c.id);\n                    if (busy) return;\n                    setContactId(c.id);\n                    setError(null);\n                    if (status === \"error\") setStatus(\"idle\");\n                  }}\n                  className={`flex min-w-[56px] max-w-[66px] flex-1 cursor-pointer flex-col items-center gap-1.5 rounded-[14px] py-1.5 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n                >\n                  <span\n                    className=\"flex rounded-full\"\n                    style={{\n                      boxShadow: on ? \"0 0 0 2px var(--card), 0 0 0 3.5px var(--primary)\" : \"0 0 0 2px var(--card), 0 0 0 2px transparent\",\n                      opacity: composing && contactId && !on ? 0.45 : 1,\n                      transition: reduced ? \"none\" : `box-shadow 260ms ${EASE}, opacity 260ms ${EASE}`,\n                    }}\n                  >\n                    <Face name={c.name} src={c.src} size={44} />\n                  </span>\n                  <span className={`max-w-full truncate text-[12px] ${on ? \"font-medium text-foreground\" : \"text-muted-foreground\"}`}>{c.name.split(\" \")[0]}</span>\n                </button>\n              );\n            })}\n          </div>\n        </div>\n      )}\n\n      <Faces active={composing ? 1 : 0} reduced={reduced} ready={ready}>\n        <div className=\"px-3 pb-1.5 pt-3\">\n          <div className=\"flex h-7 items-center justify-between\">\n            <h3 className=\"text-[12.5px] font-normal text-muted-foreground\">Activity</h3>\n            {shown.transactions.length > visibleTransactions && (\n              <button type=\"button\" aria-expanded={expanded} onClick={() => setExpanded((v) => !v)} className={`-mr-2 cursor-pointer rounded-full px-2 py-1 text-[12.5px] text-foreground hover:bg-accent ${INTERACTION_FOCUS}`}>\n                <TextMorph>{expanded ? \"Show less\" : \"See all\"}</TextMorph>\n              </button>\n            )}\n          </div>\n          <div className=\"relative\">\n            <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n            {rows.length ? (\n              <ul ref={listRef} className=\"relative\">\n                {rows.map((t) => {\n                  const pending = t.status === \"pending\";\n                  return (\n                    <li key={t.id} data-row-id={t.id} className=\"flex items-center gap-3 border-t border-border py-3 first:border-t-0\">\n                      <Face name={t.title.replace(/^(To|From)\\s+/, \"\")} src={t.src} icon={t.icon} size={42} />\n                      <span className=\"min-w-0 flex-1\">\n                        <span className=\"block truncate text-[14.5px] font-medium tracking-[-0.01em] text-foreground\">{t.title}</span>\n                        <span className=\"block text-[12.5px] text-muted-foreground\" suppressHydrationWarning>\n                          {money.when(t.date)}\n                        </span>\n                      </span>\n                      <span className=\"flex flex-col items-end\">\n                        <span\n                          className={`text-[15px] font-medium tracking-[-0.01em] tabular-nums ${pending ? \"text-muted-foreground\" : t.amount > 0 ? \"text-emerald-600 dark:text-emerald-400\" : \"text-foreground\"}`}\n                          style={{ transition: \"color 300ms\" }}\n                        >\n                          {money.signed(t.amount)}\n                        </span>\n                        {t.status && (\n                          <span className=\"text-[11.5px] text-muted-foreground\">\n                            <TextMorph>{pending ? \"Requested\" : \"Paid\"}</TextMorph>\n                          </span>\n                        )}\n                      </span>\n                    </li>\n                  );\n                })}\n              </ul>\n            ) : (\n              <p className=\"py-6 text-center text-[13px] text-muted-foreground\">No activity yet</p>\n            )}\n          </div>\n        </div>\n\n        <div className=\"px-1 pb-1 pt-4\">\n          <input\n            value={note}\n            onChange={(e) => setNote(e.target.value)}\n            maxLength={60}\n            disabled={busy}\n            placeholder={sending ? \"What’s it for?\" : \"Add a note\"}\n            aria-label=\"Note (optional)\"\n            enterKeyHint={sending ? \"send\" : \"done\"}\n            className=\"h-11 w-full rounded-full border border-border bg-transparent px-4 text-[14px] text-foreground outline-none transition-[border-color,box-shadow] placeholder:text-muted-foreground focus:border-ring focus:ring-2 focus:ring-ring/20 max-sm:text-[16px]\"\n          />\n          <div className=\"mt-2.5 grid grid-cols-3 gap-1.5\">\n            {KEYS.map((k) => (\n              <button\n                key={k}\n                type=\"button\"\n                tabIndex={-1}\n                aria-label={k === \"back\" ? \"Delete\" : k === \".\" ? \"Decimal point\" : k}\n                onClick={() => key(k)}\n                className={`flex h-12 cursor-pointer items-center justify-center rounded-[16px] text-[20px] font-medium tabular-nums text-foreground hover:bg-accent active:bg-accent ${INTERACTION_PRESS} [-webkit-tap-highlight-color:transparent]`}\n              >\n                {k === \"back\" ? <Delete aria-hidden=\"true\" className=\"size-5 text-muted-foreground\" /> : k === \".\" ? money.decimal : k}\n              </button>\n            ))}\n          </div>\n          <StatusButton\n            status={status}\n            labels={{ idle: action, pending: sending ? \"Sending\" : \"Requesting\", success: sending ? \"Sent\" : \"Requested\", error: \"Try again\" }}\n            disabled={!valid && status !== \"error\" && !busy}\n            onClick={() => void submit()}\n            className=\"mt-2.5 h-12 w-full text-[15px] disabled:opacity-50\"\n            style={status === \"error\" ? undefined : LIT}\n          />\n          {!sending && paymentLink && (\n            <div className=\"mt-2 flex items-center justify-center gap-1 text-[12.5px] text-muted-foreground\">\n              Or share\n              <CopyButton text={paymentLink} label={paymentLink} copiedLabel=\"Link copied\" showLabel size=\"sm\" />\n            </div>\n          )}\n        </div>\n      </Faces>\n\n      <span className=\"sr-only\" role=\"status\">\n        {said}\n      </span>\n    </section>\n  );\n}\n\nexport default Wallet;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · wallet · 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 * FACES: the states of one surface, in the same place\n *\n * The active face is in flow; the others are laid over it, faded\n * and nudged aside (faces before it to the left, faces after it to\n * the right), so switching reads as the surface turning to its\n * next side. The height eases between the faces' measured heights,\n * so nothing around it jumps. Used by the blocks (the wallet's\n * activity and keypad, a pricing card and its checkout, a booking's\n * times, details and confirmation).\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const FACES_MORPH = 460;\n\nexport function Faces({\n  active,\n  reduced,\n  ready,\n  className = \"\",\n  children,\n}: {\n  active: number;\n  reduced: boolean;\n  /** False until the first paint, so the faces don't animate into place on mount. */\n  ready: boolean;\n  className?: string;\n  children: React.ReactNode[];\n}) {\n  const refs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [heights, setHeights] = React.useState<number[] | null>(null);\n  const count = children.length;\n\n  React.useLayoutEffect(() => {\n    const els = refs.current.slice(0, count);\n    const observer = new ResizeObserver(() => {\n      const next = els.map((el) => el?.offsetHeight ?? 0);\n      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));\n    });\n    els.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [count]);\n\n  const face = (i: number): React.CSSProperties => {\n    const on = i === active;\n    return {\n      opacity: on ? 1 : 0,\n      transform: on || reduced ? \"none\" : `translateX(${i < active ? -18 : 18}px)`,\n      filter: on || reduced ? \"none\" : \"blur(4px)\",\n      transition: !ready\n        ? \"none\"\n        : reduced\n          ? \"opacity 160ms ease-out\"\n          : on\n            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,\n    };\n  };\n\n  return (\n    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : \"none\" }}>\n      {children.map((child, i) => (\n        <div\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          inert={active !== i || undefined}\n          aria-hidden={active !== i || undefined}\n          className={active === i ? \"relative\" : \"pointer-events-none absolute inset-x-0 top-0\"}\n          style={face(i)}\n        >\n          {child}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · wallet · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · wallet · 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":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/wallet. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}