{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"buy-box","type":"registry:block","title":"Buy box","description":"A product buy box whose swatches repaint the product: pick a colour and size, and Add to bag throws the product into the bag.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/input.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/stepper-input.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/buy-box.tsx","type":"registry:component","content":"// Hairline UI · buy-box · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { BellRing, ShoppingBag } from \"lucide-react\";\nimport { Faces } from \"./faces\";\nimport { Input } from \"./input\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { StepperInput } from \"./stepper-input\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * BUY BOX: the swatch that repaints the product\n *\n *   stage     the product stands on a pedestal of engraved rings\n *             in a stage painted in the colour you picked; a new\n *             colour floods the stage and ripples the rings\n *   options   swatches and sizes from your variants; what isn't\n *             made or is sold out is struck through; the price\n *             rolls when the variant costs more or less\n *   add       Add to bag goes Adding → Added and the product is\n *             thrown into the bag in the corner, which bumps as\n *             its count rolls\n *   sold out  the button becomes Notify me, and an email field\n *             grows out of it\n *\n * The catalogue is yours: pass options and variants (price and\n * stock per variant), and do the work in onAddToCart and onNotify.\n * Throw an Error and its message shows under the button.\n * ───────────────────────────────────────────────────────── */\n\nexport interface BuyBoxValue {\n  id: string;\n  label: string;\n  /** A CSS colour: makes this a swatch, and paints the stage when picked. */\n  swatch?: string;\n}\n\nexport interface BuyBoxOption {\n  id: string;\n  /** e.g. \"Colour\", \"Size\". */\n  label: string;\n  values: BuyBoxValue[];\n}\n\nexport interface BuyBoxVariant {\n  id: string;\n  /** The value id picked for each option, e.g. { colour: \"sand\", size: \"m\" }. */\n  options: Record<string, string>;\n  price: number;\n  /** The old price, shown struck through. */\n  compareAt?: number;\n  /** Left in stock; leave out for plenty, 0 is sold out. */\n  stock?: number;\n  /** A photo of this variant; the stage shows `icon` when missing. */\n  image?: string;\n}\n\nexport interface BuyBoxProps {\n  name: string;\n  brand?: string;\n  options: BuyBoxOption[];\n  variants: BuyBoxVariant[];\n  /** Starting picks; defaults to the first variant in stock. */\n  defaultSelection?: Record<string, string>;\n  /** The product's glyph on the stage when there's no image. */\n  icon?: React.ReactNode;\n  /** Items already in the bag. */\n  bagCount?: number;\n  /** \"Only 3 left\" from this many down. */\n  lowStockAt?: number;\n  /** Add to the bag; throw an Error to say why not. */\n  onAddToCart: (variant: BuyBoxVariant, quantity: number) => Promise<unknown>;\n  /** Shows Notify me on sold-out variants. */\n  onNotify?: (variant: BuyBoxVariant, email: string) => Promise<unknown>;\n  currency?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nconst subscribeNothing = () => () => {};\n/** False on the server and while hydrating: text is formatted in one fixed locale until then, so the server's and the visitor's never disagree. */\nconst useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\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 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};\nconst money = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: \"currency\", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 });\n\nconst matches = (v: BuyBoxVariant, pick: Record<string, string>, skip?: string) => Object.entries(pick).every(([k, val]) => k === skip || v.options[k] === val);\n\n/** The pedestal: fine engraved rings the product stands on. */\nfunction Rings({ ringsRef }: { ringsRef: React.RefObject<SVGSVGElement | null> }) {\n  return (\n    <svg ref={ringsRef} aria-hidden=\"true\" viewBox=\"0 0 200 80\" className=\"absolute bottom-[6%] left-1/2 w-[78%] -translate-x-1/2 overflow-visible\" fill=\"none\" stroke=\"currentColor\">\n      {Array.from({ length: 7 }, (_, i) => (\n        <ellipse key={i} cx=\"100\" cy=\"40\" rx={30 + i * 12} ry={8 + i * 3.6} strokeWidth=\"0.8\" strokeOpacity={0.34 - i * 0.04} />\n      ))}\n    </svg>\n  );\n}\n\nexport function BuyBox({\n  name,\n  brand,\n  options,\n  variants,\n  defaultSelection,\n  icon,\n  bagCount = 0,\n  lowStockAt = 5,\n  onAddToCart,\n  onNotify,\n  currency = \"USD\",\n  locale,\n  className = \"\",\n}: BuyBoxProps) {\n  const reduced = useInteractionReducedMotion();\n  const hydrated = useHydrated();\n  const baseId = React.useId();\n  const [pick, setPick] = React.useState<Record<string, string>>(() => defaultSelection ?? (variants.find((v) => v.stock !== 0) ?? variants[0])?.options ?? {});\n  const [quantity, setQuantity] = React.useState(1);\n  const [add, setAdd] = React.useState<{ status: ActionStatus; error?: string }>({ status: \"idle\" });\n  const [notify, setNotify] = React.useState<{ open: boolean; email: string; status: ActionStatus; error?: string }>({ open: false, email: \"\", status: \"idle\" });\n  const [added, setAdded] = React.useState(0);\n  const [ready, setReady] = React.useState(false);\n  const [said, setSaid] = React.useState(\"\");\n\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const productRef = React.useRef<HTMLDivElement>(null);\n  const bagRef = React.useRef<HTMLSpanElement>(null);\n  const ringsRef = React.useRef<SVGSVGElement>(null);\n  const emailRef = React.useRef<HTMLInputElement>(null);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const variant = variants.find((v) => matches(v, pick) && options.every((o) => v.options[o.id] !== undefined)) ?? null;\n  const swatchOption = options.find((o) => o.values.some((v) => v.swatch));\n  const swatch = swatchOption?.values.find((v) => v.id === pick[swatchOption.id])?.swatch ?? \"var(--primary)\";\n  const price = variant?.price ?? Math.min(...variants.map((v) => v.price));\n  const soldOut = variant?.stock === 0;\n  const low = variant?.stock !== undefined && variant.stock > 0 && variant.stock <= lowStockAt;\n  const saving = variant?.compareAt && variant.compareAt > variant.price ? Math.round((1 - variant.price / variant.compareAt) * 100) : 0;\n  const busy = add.status === \"pending\";\n  const bag = bagCount + added;\n\n  /** Is this value made with the rest of what's picked, and in stock? */\n  const state = (optionId: string, valueId: string): \"ok\" | \"out\" | \"none\" => {\n    const made = variants.filter((v) => v.options[optionId] === valueId && matches(v, pick, optionId));\n    if (!made.length) return \"none\";\n    return made.some((v) => v.stock !== 0) ? \"ok\" : \"out\";\n  };\n\n  const choose = (optionId: string, valueId: string) => {\n    if (busy) return;\n    const next = { ...pick, [optionId]: valueId };\n    // Not made with the rest of the picks: move the others to the closest variant that is.\n    const exact = variants.some((v) => matches(v, next));\n    const fallback = exact ? next : (variants.find((v) => v.options[optionId] === valueId && v.stock !== 0) ?? variants.find((v) => v.options[optionId] === valueId))?.options;\n    setPick(fallback ?? next);\n    setAdd({ status: \"idle\" });\n    if (notify.open) setNotify((n) => ({ ...n, open: false, status: \"idle\", error: undefined }));\n    if (optionId === swatchOption?.id && !reduced && valueId !== pick[optionId]) {\n      // The rings are centred with `translate`, so the ripple only scales.\n      ringsRef.current?.animate([{ transform: \"scale(0.9)\", opacity: 0.35 }, { transform: \"scale(1)\", opacity: 1 }], { duration: 620, easing: EASE_THROW });\n    }\n  };\n\n  // The product is thrown into the bag: a copy arcs from the stage to the bag, which bumps as it lands.\n  const fly = () => {\n    const section = sectionRef.current;\n    const from = productRef.current;\n    const to = bagRef.current;\n    if (!section || !from || !to || reduced) {\n      setAdded((n) => n + quantity);\n      return;\n    }\n    const box = section.getBoundingClientRect();\n    const a = from.getBoundingClientRect();\n    const b = to.getBoundingClientRect();\n    const copy = from.cloneNode(true) as HTMLElement;\n    copy.setAttribute(\"aria-hidden\", \"true\");\n    Object.assign(copy.style, { position: \"absolute\", left: `${a.left - box.left}px`, top: `${a.top - box.top}px`, width: `${a.width}px`, height: `${a.height}px`, margin: \"0\", zIndex: \"20\", pointerEvents: \"none\", willChange: \"transform\" });\n    section.appendChild(copy);\n    const dx = b.left + b.width / 2 - (a.left + a.width / 2);\n    const dy = b.top + b.height / 2 - (a.top + a.height / 2);\n    const s = Math.min(b.height / a.height, 0.3);\n    const flight = copy.animate(\n      [\n        { transform: \"translate(0,0) scale(1)\", opacity: 1 },\n        { transform: `translate(${dx * 0.55}px, ${dy * 0.35 - 40}px) scale(${(1 + s) / 2}) rotate(-8deg)`, opacity: 1, offset: 0.5 },\n        { transform: `translate(${dx}px, ${dy}px) scale(${s}) rotate(-14deg)`, opacity: 0.2 },\n      ],\n      { duration: 680, easing: \"cubic-bezier(0.5,0,0.25,1)\", fill: \"forwards\" },\n    );\n    flight.onfinish = flight.oncancel = () => {\n      copy.remove();\n      setAdded((n) => n + quantity);\n      bagRef.current?.animate([{ transform: \"scale(1)\" }, { transform: \"scale(1.16)\" }, { transform: \"scale(1)\" }], { duration: 420, easing: EASE_THROW });\n    };\n  };\n\n  const addToBag = async () => {\n    if (!variant || busy || soldOut) return;\n    setAdd({ status: \"pending\" });\n    try {\n      await onAddToCart(variant, quantity);\n      setAdd({ status: \"success\" });\n      setSaid(`Added ${quantity} ${name} to your bag.`);\n      fly();\n    } catch (e) {\n      setAdd({ status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t add that. Try again.\" });\n    }\n  };\n\n  const sendNotify = async () => {\n    if (!variant || !onNotify) return;\n    if (!notify.open) {\n      setNotify((n) => ({ ...n, open: true }));\n      window.setTimeout(() => emailRef.current?.focus({ preventScroll: true }), reduced ? 0 : 300);\n      return;\n    }\n    if (!EMAIL.test(notify.email.trim())) return setNotify((n) => ({ ...n, status: \"idle\", error: \"Add an email we can write to\" }));\n    setNotify((n) => ({ ...n, status: \"pending\", error: undefined }));\n    try {\n      await onNotify(variant, notify.email.trim());\n      setNotify((n) => ({ ...n, status: \"success\" }));\n      setSaid(\"We’ll email you when it’s back.\");\n    } catch (e) {\n      setNotify((n) => ({ ...n, status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t save that. Try again.\" }));\n    }\n  };\n\n  const stock = soldOut ? \"Sold out\" : low ? `Only ${variant?.stock} left` : variant ? \"In stock, ships today\" : \"Pick an option\";\n  const fmt = (n: number) => money(currency, n);\n\n  return (\n    <section ref={sectionRef} aria-labelledby={`${baseId}-name`} className={`relative w-full max-w-[400px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 400px)\" }} />\n      <div className=\"@container w-full\">\n        {/* The one lit surface: the stage, painted in the colour you picked. */}\n        <div\n          className=\"relative h-[220px] overflow-clip rounded-[22px]\"\n          style={{\n            backgroundColor: mix(swatch, 48, \"var(--card)\"),\n            backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix(\"white\", 34)} 0%, transparent 55%), linear-gradient(175deg, transparent 45%, ${mix(\"black\", 14)} 100%)`,\n            boxShadow: `inset 0 1px 0 ${mix(\"white\", 40)}, inset 0 0 0 1px ${mix(swatch, 30)}`,\n            color: mix(swatch, 62, \"black\"),\n            transition: reduced ? \"none\" : `background-color 520ms ${EASE}, color 520ms ${EASE}, box-shadow 520ms ${EASE}`,\n          }}\n        >\n          <Rings ringsRef={ringsRef} />\n          <div ref={productRef} className=\"absolute inset-x-0 top-[14%] mx-auto flex size-[128px] items-center justify-center [&_svg]:size-[92px]\">\n            {variant?.image ? (\n              // eslint-disable-next-line @next/next/no-img-element\n              <img key={variant.image} src={variant.image} alt=\"\" decoding=\"async\" className=\"size-full object-contain motion-safe:animate-[ui-fade-in_420ms_ease-out_both]\" />\n            ) : (\n              <span aria-hidden=\"true\" className=\"flex [&_svg]:stroke-[1.2]\">\n                {icon ?? <ShoppingBag />}\n              </span>\n            )}\n          </div>\n          {saving > 0 && (\n            <span className=\"absolute left-3 top-3 rounded-full bg-foreground px-2 py-1 text-[11.5px] font-medium tabular-nums text-background\">\n              −<NumberRoll value={saving} duration={600} />%\n            </span>\n          )}\n          <span ref={bagRef} className=\"absolute right-3 top-3 inline-flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-[12.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)]\" aria-label={`${bag} in your bag`}>\n            <ShoppingBag aria-hidden=\"true\" className=\"size-3.5\" />\n            <NumberRoll value={bag} duration={600} aria-hidden=\"true\" />\n          </span>\n        </div>\n\n        <div className=\"px-2 pb-1 pt-3\">\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              {brand && <p className=\"text-[12px] text-muted-foreground\">{brand}</p>}\n              <h2 id={`${baseId}-name`} className=\"text-[18px] font-medium leading-tight tracking-[-0.02em]\">\n                {name}\n              </h2>\n            </div>\n            <div className=\"shrink-0 text-right\">\n              <p className=\"text-[22px] font-medium leading-none tracking-[-0.03em] tabular-nums\">\n                <NumberRoll value={price} locales={locale} format={fmt(price)} duration={800} />\n              </p>\n              {saving > 0 && variant?.compareAt && (\n                <p className=\"mt-1 text-[12px] text-muted-foreground tabular-nums line-through\">{new Intl.NumberFormat(locale ?? (hydrated ? undefined : \"en-US\"), fmt(variant.compareAt)).format(variant.compareAt)}</p>\n              )}\n            </div>\n          </div>\n\n          {options.map((o) => {\n            const chosen = o.values.find((v) => v.id === pick[o.id]);\n            return (\n              <div key={o.id} className=\"mt-3.5\">\n                <p className=\"text-[12.5px] text-muted-foreground\">\n                  {o.label} · <TextMorph className=\"text-foreground\">{chosen?.label ?? \"Choose\"}</TextMorph>\n                </p>\n                <div role=\"radiogroup\" aria-label={o.label} className=\"mt-2 flex flex-wrap gap-1.5\">\n                  {o.values.map((v) => {\n                    const on = v.id === pick[o.id];\n                    const st = state(o.id, v.id);\n                    const label = `${v.label}${st === \"out\" ? \", sold out\" : st === \"none\" ? \", not made in this combination\" : \"\"}`;\n                    return v.swatch ? (\n                      <button\n                        key={v.id}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={on}\n                        aria-label={label}\n                        title={v.label}\n                        onClick={() => choose(o.id, v.id)}\n                        className={`relative size-8 cursor-pointer rounded-full ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}\n                        style={{\n                          background: v.swatch,\n                          boxShadow: on\n                            ? `inset 0 0 0 1px ${mix(\"black\", 14)}, 0 0 0 2px var(--card), 0 0 0 3.5px var(--primary)`\n                            : `inset 0 0 0 1px ${mix(\"black\", 14)}, 0 0 0 2px var(--card), 0 0 0 2px transparent`,\n                          opacity: st === \"ok\" ? 1 : 0.45,\n                          transition: reduced ? \"none\" : `box-shadow 280ms ${EASE_THROW}, opacity 200ms`,\n                        }}\n                      >\n                        {st !== \"ok\" && <span aria-hidden=\"true\" className=\"absolute left-1/2 top-1/2 h-px w-[130%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-foreground/60\" />}\n                      </button>\n                    ) : (\n                      <button\n                        key={v.id}\n                        type=\"button\"\n                        role=\"radio\"\n                        aria-checked={on}\n                        aria-label={label}\n                        onClick={() => choose(o.id, v.id)}\n                        className={`h-9 min-w-11 cursor-pointer rounded-full px-3 text-[13px] tabular-nums ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${\n                          on ? \"bg-primary/[0.08] font-medium text-primary shadow-[inset_0_0_0_1.5px_var(--primary)]\" : \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\"\n                        } ${st !== \"ok\" ? \"text-muted-foreground line-through decoration-1\" : \"\"}`}\n                      >\n                        {v.label}\n                      </button>\n                    );\n                  })}\n                </div>\n              </div>\n            );\n          })}\n\n          <p className={`mt-3.5 flex items-center gap-1.5 text-[12.5px] ${soldOut ? \"text-red-600 dark:text-red-400\" : low ? \"text-amber-600 dark:text-amber-400\" : \"text-muted-foreground\"}`}>\n            <span aria-hidden=\"true\" className={`size-1.5 rounded-full ${soldOut ? \"bg-red-500\" : low ? \"bg-amber-500\" : \"bg-emerald-500\"}`} />\n            <TextMorph>{stock}</TextMorph>\n          </p>\n\n          <div className=\"mt-2.5\">\n            <Faces active={notify.open ? 1 : 0} reduced={reduced} ready={ready}>\n              <div className=\"flex items-center gap-2\">\n                <StepperInput label=\"Quantity\" value={quantity} min={1} max={variant?.stock && variant.stock > 0 ? variant.stock : 10} onValueChange={setQuantity} disabled={soldOut || busy} />\n                {soldOut && onNotify ? (\n                  <button type=\"button\" onClick={() => void sendNotify()} className={`inline-flex h-11 flex-1 cursor-pointer items-center justify-center gap-2 rounded-full text-[14.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                    <BellRing aria-hidden=\"true\" className=\"size-4\" />\n                    Notify me\n                  </button>\n                ) : (\n                  <StatusButton\n                    status={add.status}\n                    labels={{ idle: soldOut ? \"Sold out\" : \"Add to bag\", pending: \"Adding\", success: \"Added\", error: \"Try again\" }}\n                    icon={<ShoppingBag aria-hidden=\"true\" />}\n                    disabled={!variant || soldOut}\n                    onReset={() => setAdd({ status: \"idle\" })}\n                    resetAfter={1500}\n                    onClick={() => void addToBag()}\n                    className=\"h-11 flex-1 text-[14.5px] disabled:opacity-45\"\n                    style={add.status === \"error\" ? undefined : LIT}\n                  />\n                )}\n              </div>\n              <form\n                className=\"flex items-start gap-2\"\n                onSubmit={(e) => {\n                  e.preventDefault();\n                  void sendNotify();\n                }}\n              >\n                <Input\n                  ref={emailRef}\n                  aria-label=\"Email for a restock note\"\n                  type=\"email\"\n                  inputMode=\"email\"\n                  autoComplete=\"email\"\n                  placeholder=\"you@email.com\"\n                  value={notify.email}\n                  error={notify.error}\n                  disabled={notify.status === \"pending\" || notify.status === \"success\"}\n                  onValueChange={(email) => setNotify((n) => ({ ...n, email, error: undefined, status: n.status === \"error\" ? \"idle\" : n.status }))}\n                  className=\"flex-1\"\n                />\n                <StatusButton type=\"submit\" status={notify.status} labels={{ idle: \"Notify me\", pending: \"Saving\", success: \"You’re on the list\", error: \"Try again\" }} className=\"h-9 shrink-0 px-4 text-[13px]\" style={notify.status === \"error\" ? undefined : LIT} />\n              </form>\n            </Faces>\n            {add.error && (\n              <p role=\"alert\" className=\"mt-1.5 text-[12px] text-red-600 dark:text-red-400\">\n                {add.error}\n              </p>\n            )}\n          </div>\n        </div>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default BuyBox;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · buy-box · 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/field.tsx","type":"registry:component","content":"// Hairline UI · buy-box · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · buy-box · 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"}],"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"}},"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/buy-box. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}