{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cookie-consent","type":"registry:block","title":"Cookie consent","description":"A cookie banner with a mascot: every cookie you allow flies to Crumb, who catches it, nibbles it and gets rounder and happier.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/switch.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/cookie-consent.tsx","type":"registry:component","content":"// Hairline UI · cookie-consent · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Crumb, type CrumbHandle, type Point } from \"./cookie-crumb\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { Switch } from \"./switch\";\nimport { TextMorph } from \"./text-morph\";\nimport { useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * COOKIE CONSENT: every cookie you allow goes to Crumb\n *\n *   ask       Crumb, a dough bun, sits on the lit stage looking\n *             at the switches, hungry; Necessary is always on\n *   allow     switch a category on and a cookie leaps from the\n *             switch into Crumb's mouth: it watches it come,\n *             opens up, chomps, crumbs fly, and it gets rounder\n *             and happier; with everything allowed it's stuffed,\n *             eyes squeezed shut, sparkling\n *   take back switch one off and Crumb hands the cookie back,\n *             with a bite out of it, and shrinks a little\n *   decide    Reject all hands every cookie back; Accept all\n *             feeds the rest; the main button says Accept all or\n *             Save choices, and saving waves goodbye\n *\n * The choice is yours to keep: onSave gets { [category]: on }\n * to write to a cookie, your consent platform or your API.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CookieCategory {\n  id: string;\n  label: string;\n  description?: string;\n  /** Always on and can't be switched off, e.g. sign-in and security. */\n  required?: boolean;\n}\n\nexport type CookieChoices = Record<string, boolean>;\n\nexport interface CookieConsentProps {\n  categories?: CookieCategory[];\n  value?: CookieChoices;\n  defaultValue?: CookieChoices;\n  onValueChange?: (choices: CookieChoices) => void;\n  /** Keep the choices (a cookie, your consent platform, your API); throw an Error to say why not. */\n  onSave: (choices: CookieChoices) => Promise<unknown>;\n  title?: string;\n  description?: string;\n  /** Your cookie policy, linked under the choices. */\n  policyHref?: string;\n  /** What the mascot is called in its lines. */\n  name?: string;\n  className?: string;\n}\n\nconst CATEGORIES: CookieCategory[] = [\n  { id: \"necessary\", label: \"Necessary\", description: \"Sign-in, security and your choices here.\", required: true },\n  { id: \"analytics\", label: \"Analytics\", description: \"Counts visits so we know what to fix.\" },\n  { id: \"marketing\", label: \"Marketing\", description: \"Measures ads and where you came from.\" },\n  { id: \"personalization\", label: \"Personalization\", description: \"Remembers what you like.\" },\n];\nconst EMPTY: CookieChoices = {};\n\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 STAGE: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(90% 80% at 50% 100%, ${mix(\"var(--primary-foreground)\", 20)} 0%, transparent 60%), radial-gradient(120% 90% at 0% 0%, ${mix(\"var(--primary-foreground)\", 16)} 0%, transparent 55%), linear-gradient(175deg, transparent 40%, ${mix(\"black\", 22)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\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/* Crumb's colours: the bun takes on your theme; the cookie, blush and sparkles are its own, and each can be overridden. */\nconst CRUMB = {\n  \"--crumb-hi\": \"var(--primary-foreground)\",\n  \"--crumb-body\": mix(\"var(--primary-foreground)\", 93, \"var(--primary)\"),\n  \"--crumb-edge\": mix(\"var(--primary-foreground)\", 76, \"var(--primary)\"),\n  \"--crumb-line\": mix(\"var(--primary)\", 55, \"black\"),\n  \"--crumb-shade\": mix(\"var(--primary)\", 40, \"var(--primary-foreground)\"),\n  \"--crumb-ink\": mix(\"var(--primary)\", 30, \"black\"),\n  \"--crumb-floor\": mix(\"var(--primary-foreground)\", 40),\n  \"--crumb-blush\": \"oklch(0.78 0.12 12)\",\n  \"--crumb-spark\": \"var(--primary-foreground)\",\n  \"--crumb-cookie\": \"oklch(0.74 0.1 70)\",\n  \"--crumb-cookie-rim\": \"oklch(0.6 0.1 58)\",\n  \"--crumb-chip\": \"oklch(0.36 0.05 45)\",\n} as React.CSSProperties;\n\nexport function CookieConsent({\n  categories = CATEGORIES,\n  value,\n  defaultValue = EMPTY,\n  onValueChange,\n  onSave,\n  title = \"Cookie settings\",\n  description,\n  policyHref,\n  name = \"Crumb\",\n  className = \"\",\n}: CookieConsentProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const [own, setOwn] = React.useState<CookieChoices>(defaultValue);\n  const choices = value ?? own;\n  const [act, setAct] = React.useState<{ which: \"accept\" | \"reject\" | null; status: ActionStatus; error?: string }>({ which: null, status: \"idle\" });\n  const [said, setSaid] = React.useState(\"\");\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const overlayRef = React.useRef<SVGSVGElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const crumb = React.useRef<CrumbHandle>(null);\n  const switches = React.useRef(new Map<string, HTMLElement>());\n\n  const optional = categories.filter((c) => !c.required);\n  const isOn = (c: CookieCategory) => Boolean(c.required || choices[c.id]);\n  const fed = optional.filter(isOn).length;\n  const allOn = fed === optional.length;\n  const busy = act.status === \"pending\";\n\n  const commit = (next: CookieChoices) => {\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  /** Where a category's switch is, in the block's own px. */\n  const switchAt = (id: string): Point => {\n    const section = sectionRef.current;\n    const el = switches.current.get(id);\n    if (!section || !el) return { x: 0, y: 0 };\n    const box = section.getBoundingClientRect();\n    const k = box.width / (section.offsetWidth || box.width) || 1;\n    const r = el.getBoundingClientRect();\n    return { x: (r.left + r.width / 2 - box.left) / k, y: (r.top + r.height / 2 - box.top) / k };\n  };\n\n  const toggle = (c: CookieCategory, on: boolean) => {\n    if (busy || c.required) return;\n    commit({ ...choices, [c.id]: on });\n    if (on) crumb.current?.feed(switchAt(c.id));\n    else crumb.current?.giveBack(switchAt(c.id));\n    setSaid(`${c.label} ${on ? \"allowed\" : \"off\"}.`);\n    if (act.status !== \"idle\") setAct({ which: null, status: \"idle\" });\n  };\n\n  const save = async (which: \"accept\" | \"reject\", next: CookieChoices, wait: number) => {\n    setAct({ which, status: \"pending\" });\n    try {\n      // let the cookies land before the choice is kept\n      await new Promise((r) => window.setTimeout(r, wait));\n      await onSave(next);\n      setAct({ which, status: \"success\" });\n      setSaid(which === \"reject\" ? \"Saved: only necessary cookies.\" : `Saved your cookie choices.`);\n      crumb.current?.wave();\n    } catch (e) {\n      setAct({ which, status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t save that. Try again.\" });\n    }\n  };\n\n  const acceptAll = () => {\n    if (busy) return;\n    const off = optional.filter((c) => !choices[c.id]);\n    const keep = fed > 0 && !allOn;\n    if (keep) return void save(\"accept\", { ...choices, ...Object.fromEntries(categories.filter((c) => c.required).map((c) => [c.id, true])) }, 0);\n    const next = Object.fromEntries(categories.map((c) => [c.id, true]));\n    commit(next);\n    off.forEach((c, i) => crumb.current?.feed(switchAt(c.id), i * 170));\n    void save(\"accept\", next, reduced || !off.length ? 0 : off.length * 170 + 620);\n  };\n\n  const rejectAll = () => {\n    if (busy) return;\n    const on = optional.filter((c) => choices[c.id]);\n    const next = Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required)]));\n    commit(next);\n    on.forEach((c, i) => crumb.current?.giveBack(switchAt(c.id), i * 140));\n    void save(\"reject\", next, reduced || !on.length ? 0 : on.length * 140 + 640);\n  };\n\n  const mood = fed === 0 ? `${name} is hungry` : allOn ? `${name} is stuffed` : `${name} could eat more`;\n  const mainLabel = fed > 0 && !allOn ? \"Save choices\" : \"Accept all\";\n\n  return (\n    <section ref={sectionRef} aria-labelledby={`${baseId}-title`} className={`relative w-full max-w-[380px] rounded-[28px] p-2 text-card-foreground ${className}`} style={{ ...CARD, ...CRUMB }}>\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, 380px)\" }} />\n      <div className=\"@container w-full\">\n        {/* The one lit surface: Crumb's stage. */}\n        <div ref={stageRef} className=\"relative h-[172px] overflow-clip rounded-[22px] text-primary-foreground\" style={STAGE}>\n          <p className=\"absolute left-3.5 top-3 z-10 text-[12.5px] font-medium text-primary-foreground/80\">\n            <TextMorph>{mood}</TextMorph>\n          </p>\n          <Crumb ref={crumb} fed={fed} total={optional.length} reduced={reduced} overlay={overlayRef} stage={stageRef} />\n        </div>\n\n        <div className=\"px-2 pt-3\">\n          <h2 id={`${baseId}-title`} className=\"px-1 text-[15px] font-medium tracking-[-0.01em]\">\n            {title}\n          </h2>\n          <p className=\"mt-0.5 px-1 text-[12.5px] text-muted-foreground\">{description ?? `Every cookie you allow goes to ${name}.`}</p>\n\n          <ul className=\"mt-2\">\n            {categories.map((c) => (\n              <li key={c.id} className=\"flex min-h-12 items-center gap-3 border-t border-border/70 px-1 py-2 first:border-t-0\">\n                <span className=\"min-w-0 flex-1\">\n                  <span id={`${baseId}-${c.id}`} className=\"block text-[13.5px] font-medium\">\n                    {c.label}\n                  </span>\n                  {c.description && <span className=\"block truncate text-[12px] text-muted-foreground\">{c.description}</span>}\n                </span>\n                {c.required ? (\n                  <span className=\"shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11.5px] text-muted-foreground\">Always on</span>\n                ) : (\n                  <span\n                    ref={(el) => {\n                      if (el) switches.current.set(c.id, el);\n                      else switches.current.delete(c.id);\n                    }}\n                    className=\"flex shrink-0\"\n                  >\n                    <Switch size=\"sm\" checked={isOn(c)} aria-labelledby={`${baseId}-${c.id}`} disabled={busy} onCheckedChange={(on) => toggle(c, on)} />\n                  </span>\n                )}\n              </li>\n            ))}\n          </ul>\n\n          <div className=\"mt-2.5 grid grid-cols-2 gap-2\">\n            <StatusButton\n              variant=\"outline\"\n              status={act.which === \"reject\" ? act.status : \"idle\"}\n              labels={{ idle: \"Reject all\", pending: \"Saving\", success: \"Saved\", error: \"Try again\" }}\n              disabled={busy && act.which !== \"reject\"}\n              onClick={rejectAll}\n              className=\"h-10 w-full text-[13.5px]\"\n            />\n            <StatusButton\n              status={act.which === \"accept\" ? act.status : \"idle\"}\n              labels={{ idle: mainLabel, pending: \"Saving\", success: \"Saved\", error: \"Try again\" }}\n              disabled={busy && act.which !== \"accept\"}\n              onClick={acceptAll}\n              className=\"h-10 w-full text-[13.5px]\"\n              style={act.which === \"accept\" && act.status === \"error\" ? undefined : LIT}\n            />\n          </div>\n          <p role={act.error ? \"alert\" : undefined} className={`mt-2 pb-1 text-center text-[12px] ${act.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n            {act.error ?? (\n              <>\n                Change this any time{policyHref ? \" · \" : \".\"}\n                {policyHref && (\n                  <a href={policyHref} className=\"underline decoration-border underline-offset-2 hover:text-foreground\">\n                    Cookie policy\n                  </a>\n                )}\n              </>\n            )}\n          </p>\n        </div>\n      </div>\n\n      {/* Cookies fly over the whole block, from a switch to the mouth and back. */}\n      <svg ref={overlayRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-20 size-full overflow-visible\" />\n\n      <span className=\"sr-only\" role=\"status\">\n        {said}\n      </span>\n    </section>\n  );\n}\n\nexport default CookieConsent;\n"},{"path":"registry/cookie-crumb.tsx","type":"registry:component","content":"// Hairline UI · cookie-consent · 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 * CRUMB: a dough bun that eats the cookies you allow\n *\n * Drawn in SVG and moved by one frame loop that sleeps when\n * nothing moves. Its face is continuous, not swapped pictures:\n * how full it is (cookies eaten / cookies there are) sets its\n * width, smile, blush and, once it has everything, its happy\n * eyes and sparkles.\n *\n *   flight   a cookie arcs from a switch; Crumb watches it come\n *            and opens up as it gets close\n *   nibble   it catches the cookie in both arms and takes three\n *            bites, a chunk out of the cookie and crumbs each\n *            time, then swallows and fills out\n *   give     handing one back it pouts and lets go of a cookie\n *            with a bite out of it, which arcs back to its switch\n *\n * Cookies fly in an <svg> overlay that covers the whole block,\n * so they can travel from a switch to the mouth; positions are\n * in the block's own px. The engine lives outside React and\n * writes the SVG directly, so a flight costs no renders.\n * ───────────────────────────────────────────────────────── */\n\nexport type Point = { x: number; y: number };\n\nexport interface CrumbHandle {\n  /** A cookie flies from `from` (block px) into the mouth. */\n  feed: (from: Point, delay?: number) => void;\n  /** A bitten cookie flies from the mouth back to `to` (block px). */\n  giveBack: (to: Point, delay?: number) => void;\n  /** A happy wave, e.g. once the choices are saved. */\n  wave: () => void;\n}\n\nconst VB = { w: 200, h: 150 };\nconst BASE = 138; // the floor, in the stage's own units\nconst CX = 100;\nconst NS = \"http://www.w3.org/2000/svg\";\nconst HOLD = 860; // ms the cookie is held and nibbled\nconst BITES = [150, 430, 710];\nconst SIZE = 1.3; // cookies at the bun's scale\n\nconst ease = (k: number) => 1 - Math.pow(1 - k, 3);\nconst inOut = (k: number) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);\nconst clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));\nconst lerp = (a: number, b: number, k: number) => a + (b - a) * k;\n\ntype Spring = { x: number; v: number };\n/** One step of a spring toward its target; true once it has settled. */\nfunction step(s: Spring, target: number, dt: number, k = 170, c = 18) {\n  s.v += ((target - s.x) * k - s.v * c) * dt;\n  s.x += s.v * dt;\n  return Math.abs(target - s.x) < 1e-3 && Math.abs(s.v) < 1e-3;\n}\n\n/** The bun's outline: a dome with a soft, flat bottom, wider as it fills. */\nfunction bodyPath(w: number, h: number) {\n  const top = BASE - h;\n  const mid = BASE - h * 0.46;\n  return (\n    `M${CX - w * 0.84} ${BASE}` +\n    `C${CX - w * 1.03} ${BASE} ${CX - w * 1.05} ${mid + h * 0.17} ${CX - w} ${mid}` +\n    `C${CX - w * 0.95} ${top + h * 0.1} ${CX - w * 0.54} ${top} ${CX} ${top}` +\n    `C${CX + w * 0.54} ${top} ${CX + w * 0.95} ${top + h * 0.1} ${CX + w} ${mid}` +\n    `C${CX + w * 1.05} ${mid + h * 0.17} ${CX + w * 1.03} ${BASE} ${CX + w * 0.84} ${BASE}Z`\n  );\n}\n\n/** A cookie as SVG markup: a disc, a darker rim and chips, and three bites hidden in its mask. */\nconst COOKIE = (id: string, bitten: number) =>\n  `<defs><mask id=\"${id}\"><rect x=\"-14\" y=\"-14\" width=\"28\" height=\"28\" fill=\"white\"/>` +\n  [\n    [9, -8, 6, 12.5, -2, 4.5],\n    [-9.5, -7.5, 5.5, -4, -12, 4.5],\n    [1, 11.5, 6, 8, 9, 4],\n  ]\n    .map(([x1, y1, r1, x2, y2, r2], i) => `<g data-bite=\"${i}\" style=\"transform:scale(${i < bitten ? 1 : 0});transform-origin:${x1}px ${y1}px\"><circle cx=\"${x1}\" cy=\"${y1}\" r=\"${r1}\" fill=\"black\"/><circle cx=\"${x2}\" cy=\"${y2}\" r=\"${r2}\" fill=\"black\"/></g>`)\n    .join(\"\") +\n  `</mask></defs><g mask=\"url(#${id})\"><circle r=\"11\" fill=\"var(--crumb-cookie)\" stroke=\"var(--crumb-cookie-rim)\" stroke-width=\"1.4\"/>` +\n  `<circle cx=\"-4\" cy=\"-3.5\" r=\"1.7\" fill=\"var(--crumb-chip)\"/><circle cx=\"3.5\" cy=\"-1\" r=\"1.5\" fill=\"var(--crumb-chip)\"/>` +\n  `<circle cx=\"-1.5\" cy=\"4\" r=\"1.6\" fill=\"var(--crumb-chip)\"/><circle cx=\"5\" cy=\"5.5\" r=\"1.2\" fill=\"var(--crumb-chip)\"/><circle cx=\"-6.5\" cy=\"3\" r=\"1.1\" fill=\"var(--crumb-chip)\"/></g>`;\n\ntype Flight = { id: number; dir: 1 | -1; a: Point; b: Point; t0: number; dur: number; el: SVGGElement; held: number; bites: number };\ntype Speck = { x: number; y: number; vx: number; vy: number; t0: number; el: SVGCircleElement };\n\nexport class CrumbEngine {\n  parts: Record<string, SVGElement | null> = {};\n  overlayRef: React.RefObject<SVGSVGElement | null> | null = null;\n  stageRef: React.RefObject<HTMLElement | null> | null = null;\n  reduced = false;\n  target = 0;\n  full: Spring = { x: 0, v: 0 };\n  joy: Spring = { x: 0, v: 0 };\n  squash: Spring = { x: 0, v: 0 };\n  open: Spring = { x: 0, v: 0 };\n  arms: Spring = { x: 0, v: 0 };\n  hug: Spring = { x: 0, v: 0 };\n  pout: Spring = { x: 0, v: 0 };\n  lookX: Spring = { x: 0, v: 0 };\n  lookY: Spring = { x: 1, v: 0 };\n  blinkAt = 0;\n  chewUntil = 0;\n  waveUntil = 0;\n  flights: Flight[] = [];\n  specks: Speck[] = [];\n  seq = 0;\n  raf = 0;\n  last = 0;\n  blinkTimer = 0;\n  frame = (now: number) => this.tick(now);\n\n  get overlay() {\n    return this.overlayRef?.current ?? null;\n  }\n  get stage() {\n    return this.stageRef?.current ?? null;\n  }\n\n  part = (name: string) => (el: SVGElement | null) => {\n    this.parts[name] = el;\n  };\n\n  mount(overlay: React.RefObject<SVGSVGElement | null>, stage: React.RefObject<HTMLElement | null>, reduced: boolean, fed: number, total: number) {\n    this.overlayRef = overlay;\n    this.stageRef = stage;\n    this.reduced = reduced;\n    this.target = total ? clamp(fed / total) : 0;\n    this.full.x = this.joy.x = this.target;\n    this.draw(performance.now());\n    this.scheduleBlink();\n  }\n\n  destroy() {\n    cancelAnimationFrame(this.raf);\n    window.clearTimeout(this.blinkTimer);\n    this.raf = 0;\n    this.flights.forEach((f) => f.el.remove());\n    this.specks.forEach((s) => s.el.remove());\n    this.flights = [];\n    this.specks = [];\n  }\n\n  setTarget(fed: number, total: number) {\n    this.target = total ? clamp(fed / total) : 0;\n    if (this.reduced) {\n      this.full.x = this.joy.x = this.target;\n      this.draw(performance.now());\n      return;\n    }\n    this.wake();\n  }\n\n  scheduleBlink() {\n    if (this.reduced) return;\n    window.clearTimeout(this.blinkTimer);\n    this.blinkTimer = window.setTimeout(() => {\n      this.blinkAt = performance.now();\n      this.wake();\n      this.scheduleBlink();\n    }, 2400 + ((this.seq * 977 + 311) % 1900));\n  }\n\n  wake() {\n    if (this.raf || !this.parts.body) return;\n    this.last = performance.now();\n    this.raf = requestAnimationFrame(this.frame);\n  }\n\n  /** The mouth in block px: the stage's place in the block (layout offsets ignore transforms) plus the bun's face. */\n  mouth(): Point {\n    const host = this.stage;\n    const root = this.overlay?.parentElement;\n    if (!host || !root) return { x: 0, y: 0 };\n    let x = 0, y = 0;\n    for (let n: HTMLElement | null = host; n && n !== root; n = n.offsetParent as HTMLElement | null) {\n      x += n.offsetLeft;\n      y += n.offsetTop;\n    }\n    const k = Math.min(host.clientWidth / VB.w, host.clientHeight / VB.h);\n    const h = 84 + 4 * this.full.x;\n    return { x: x + (host.clientWidth - VB.w * k) / 2 + CX * k, y: y + (host.clientHeight - VB.h * k) / 2 + (BASE - h * 0.5 + 14) * k };\n  }\n\n  fly(dir: 1 | -1, p: Point, delay: number) {\n    const overlay = this.overlay;\n    if (this.reduced || !overlay) return;\n    const id = ++this.seq;\n    const el = document.createElementNS(NS, \"g\") as SVGGElement;\n    el.innerHTML = COOKIE(`crumb-cookie-${id}-${Math.round(p.x)}-${Math.round(p.y)}`, dir === -1 ? 1 : 0);\n    el.style.opacity = \"0\";\n    overlay.appendChild(el);\n    const m = this.mouth();\n    this.flights.push({ id, dir, a: dir === 1 ? p : { x: m.x, y: m.y + 6 }, b: dir === 1 ? { x: m.x, y: m.y + 6 } : p, t0: performance.now() + delay, dur: dir === 1 ? 600 : 640, el, held: 0, bites: 0 });\n    if (dir === -1) window.setTimeout(() => (this.pout.v += 7), delay);\n    this.wake();\n  }\n\n  wave() {\n    if (this.reduced) return;\n    this.waveUntil = performance.now() + 1300;\n    this.wake();\n  }\n\n  crumbs(at: Point, count: number, seed: number, now: number) {\n    const overlay = this.overlay;\n    if (!overlay) return;\n    for (let i = 0; i < count; i++) {\n      const a = -Math.PI * (0.1 + 0.8 * ((i * 0.37 + seed * 0.13) % 1));\n      const sp = 45 + ((i * 53 + seed * 31) % 60);\n      const el = document.createElementNS(NS, \"circle\") as SVGCircleElement;\n      el.setAttribute(\"fill\", \"var(--crumb-cookie)\");\n      el.setAttribute(\"r\", String(1.2 + ((i * 7 + seed) % 3) * 0.5));\n      overlay.appendChild(el);\n      this.specks.push({ x: at.x, y: at.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, t0: now, el });\n    }\n  }\n\n  tick(now: number) {\n    const dt = Math.min(0.05, (now - this.last) / 1000);\n    this.last = now;\n    let busy = false;\n    let near = 0;\n    let holding = false;\n    let watch: Point | null = null;\n\n    for (const f of this.flights) {\n      if (now < f.t0) {\n        busy = true;\n        continue;\n      }\n      busy = true;\n      if (f.held) {\n        // held at the mouth and nibbled: a bite at each mark, a chunk out of the cookie, crumbs, a snap of the jaw\n        holding = true;\n        const t = now - f.held;\n        while (f.bites < BITES.length && t >= BITES[f.bites]) {\n          f.el.querySelector<SVGGElement>(`[data-bite=\"${f.bites}\"]`)?.style.setProperty(\"transform\", \"scale(1)\");\n          f.bites++;\n          this.open.v -= 11;\n          this.squash.v += 5;\n          this.chewUntil = now + 260;\n          this.crumbs({ x: f.b.x + (f.bites % 2 ? 7 : -7), y: f.b.y - 2 }, 4, f.id + f.bites, now);\n        }\n        const bob = Math.sin(t / 55) * 0.8;\n        f.el.setAttribute(\"transform\", `translate(${f.b.x} ${f.b.y + bob}) rotate(${-8 + f.bites * 6}) scale(${(1 - f.bites * 0.08) * SIZE})`);\n        f.el.style.opacity = String(1 - clamp((t - HOLD + 120) / 120));\n        if (t >= HOLD) {\n          f.el.remove();\n          this.chewUntil = now + 520;\n          this.squash.v += 6;\n        }\n        continue;\n      }\n      const k = clamp((now - f.t0) / f.dur);\n      const e = f.dir === 1 ? ease(k) : inOut(k);\n      const mx = (f.a.x + f.b.x) / 2;\n      const my = Math.min(f.a.y, f.b.y) - (f.dir === 1 ? 74 : 58);\n      const x = (1 - e) * (1 - e) * f.a.x + 2 * (1 - e) * e * mx + e * e * f.b.x;\n      const y = (1 - e) * (1 - e) * f.a.y + 2 * (1 - e) * e * my + e * e * f.b.y;\n      const away = f.dir === -1 ? clamp((k - 0.86) / 0.14) : 0;\n      const pop = (f.dir === 1 ? lerp(0.5, 1, clamp(k / 0.18)) : lerp(0.85, 1, k)) * SIZE;\n      f.el.setAttribute(\"transform\", `translate(${x} ${y}) rotate(${(f.dir === 1 ? 300 : -240) * e - (f.dir === 1 ? 8 : 0)}) scale(${pop})`);\n      f.el.style.opacity = String(1 - away);\n      if (f.dir === 1) {\n        near = Math.max(near, clamp((k - 0.4) / 0.42));\n        watch = { x, y };\n      } else if (k < 0.65) watch = { x, y };\n      if (k >= 1) {\n        if (f.dir === 1) {\n          f.held = now;\n          this.squash.v += 7;\n          this.open.v -= 6;\n        } else f.el.remove();\n      }\n    }\n    this.flights = this.flights.filter((f) => f.el.isConnected);\n\n    for (const s of this.specks) {\n      const t = (now - s.t0) / 1000;\n      s.el.setAttribute(\"cx\", String(s.x + s.vx * t));\n      s.el.setAttribute(\"cy\", String(s.y + s.vy * t + 320 * t * t));\n      s.el.setAttribute(\"opacity\", String(clamp(1 - t / 0.75)));\n      if (t >= 0.75) s.el.remove();\n    }\n    this.specks = this.specks.filter((s) => s.el.isConnected);\n    if (this.specks.length) busy = true;\n\n    const chewing = now < this.chewUntil;\n    const chew = chewing ? 0.3 * Math.max(0, Math.sin(((this.chewUntil - now) / 260) * Math.PI)) : 0;\n    const waving = now < this.waveUntil;\n    const m = this.mouth();\n    const lx = watch ? clamp((watch.x - m.x) / 40, -1, 1) : 0;\n    const ly = watch ? clamp((watch.y - (m.y - 18)) / 40, -1, 1) : holding ? 0.6 : this.target < 0.34 ? 0.9 : 0;\n    const eating = this.flights.some((f) => f.dir === 1 && now >= f.t0);\n    const settled = [\n      step(this.full, eating ? this.full.x : this.target, dt, 120, 15),\n      step(this.joy, this.target, dt, 90, 14),\n      step(this.squash, 0, dt, 260, 13),\n      step(this.open, holding ? 0.5 + chew : near + chew, dt, 320, 22),\n      step(this.arms, near > 0.2 ? 1 : waving ? 0.55 : 0, dt, 200, 18),\n      step(this.hug, holding ? 1 : 0, dt, 220, 19),\n      step(this.pout, 0, dt, 60, 9),\n      step(this.lookX, lx, dt, 240, 22),\n      step(this.lookY, ly, dt, 240, 22),\n    ].every(Boolean);\n    if (!settled || chewing || waving) busy = true;\n    if (this.blinkAt && now - this.blinkAt < 160) busy = true;\n    else this.blinkAt = 0;\n\n    this.draw(now);\n    this.raf = busy ? requestAnimationFrame(this.frame) : 0;\n  }\n\n  draw(now: number) {\n    const P = this.parts;\n    const f = clamp(this.full.x);\n    const j = clamp(this.joy.x);\n    const open = clamp(this.open.x);\n    const hug = clamp(this.hug.x);\n    const sad = clamp(1 - j * 4) * (1 - open);\n    const glee = clamp((j - 0.82) / 0.18) * (1 - open) * (1 - hug);\n    const blink = this.blinkAt ? Math.sin(clamp((now - this.blinkAt) / 150) * Math.PI) : 0;\n    const w = 48 + 15 * f;\n    const h = 84 + 4 * f;\n    const sq = this.squash.x * 0.012;\n    P.bun?.setAttribute(\"transform\", `translate(${CX} ${BASE}) scale(${1 + sq} ${1 - sq * 1.2}) translate(${-CX} ${-BASE})`);\n    P.body?.setAttribute(\"d\", bodyPath(w, h));\n    const top = BASE - h;\n    const fy = BASE - h * 0.5;\n    P.gloss?.setAttribute(\"d\", `M${CX - w * 0.7} ${top + h * 0.34} Q${CX - w * 0.64} ${top + h * 0.1} ${CX - w * 0.3} ${top + h * 0.06}`);\n    P.nubL?.setAttribute(\"transform\", `translate(${CX - 17} ${top + 5}) rotate(-14)`);\n    P.nubR?.setAttribute(\"transform\", `translate(${CX + 17} ${top + 5}) rotate(14)`);\n    P.footL?.setAttribute(\"cx\", String(CX - w * 0.42));\n    P.footR?.setAttribute(\"cx\", String(CX + w * 0.42));\n    const wave = now < this.waveUntil ? Math.sin((this.waveUntil - now) / 85) * 20 : 0;\n    const arm = this.arms.x * (1 - hug);\n    // arms lift as a cookie comes, and fold in round it while it's held\n    P.armL?.setAttribute(\"transform\", `translate(${CX - w * 0.97 + 9 * hug} ${fy + 16 - 2 * hug}) rotate(${-14 - 72 * arm + 76 * hug})`);\n    P.armR?.setAttribute(\"transform\", `translate(${CX + w * 0.97 - 9 * hug} ${fy + 16 - 2 * hug}) rotate(${14 + 72 * arm - 76 * hug + wave})`);\n    const ex = 15 + 2 * f;\n    const lid = (0.8 + 0.2 * (1 - sad)) * (1 - blink) * (1 - glee);\n    const lx = this.lookX.x * 2.6;\n    const ly = this.lookY.x * 2.2;\n    for (const [side, dir] of [[\"L\", -1], [\"R\", 1]] as const) {\n      const big = 1 + 0.16 * sad;\n      P[`eye${side}`]?.setAttribute(\"transform\", `translate(${CX + dir * ex + lx} ${fy - 4 + ly}) scale(${big} ${Math.max(0.06, lid) * big})`);\n      const glint = P[`glint${side}`];\n      if (glint) glint.style.opacity = String(sad);\n      const happy = P[`happy${side}`];\n      if (happy) {\n        happy.setAttribute(\"transform\", `translate(${CX + dir * ex} ${fy - 3})`);\n        happy.style.opacity = String(glee);\n      }\n      const brow = P[`brow${side}`];\n      if (brow) {\n        brow.setAttribute(\"transform\", `translate(${CX + dir * ex} ${fy - 14 + 2 * (1 - sad)}) rotate(${dir * 20 * sad})`);\n        brow.style.opacity = String(sad);\n      }\n      const cheek = P[`cheek${side}`];\n      if (cheek) {\n        cheek.setAttribute(\"transform\", `translate(${CX + dir * (ex + 11 + 4 * f)} ${fy + 7}) scale(${1 + 0.45 * f + 0.15 * hug})`);\n        cheek.style.opacity = String(0.15 + 0.75 * j);\n      }\n    }\n    const smile = lerp(-0.85, 1, Math.sqrt(j)) - this.pout.x * 0.12;\n    const gape = open + clamp(j - 0.55) * 0.55 * (1 - open);\n    const my = fy + 12;\n    const mw = 7 + 2 * j + 2 * open;\n    const mouth = P.mouth;\n    if (mouth) {\n      const curve = 6 * smile;\n      const depth = 4 + 12 * gape;\n      mouth.setAttribute(\n        \"d\",\n        gape > 0.05\n          ? `M${CX - mw} ${my} Q${CX} ${my + curve * 0.5 - 1} ${CX + mw} ${my} Q${CX} ${my + depth + Math.max(0, curve)} ${CX - mw} ${my}Z`\n          : `M${CX - mw} ${my} Q${CX} ${my + curve} ${CX + mw} ${my}`,\n      );\n      mouth.setAttribute(\"fill\", gape > 0.05 ? \"var(--crumb-ink)\" : \"none\");\n    }\n    const sp = P.sparkles;\n    if (sp) {\n      sp.style.opacity = String(glee);\n      sp.setAttribute(\"transform\", `translate(${CX} ${top + 4}) scale(${0.85 + 0.15 * glee})`);\n    }\n    P.floor?.setAttribute(\"d\", `M${CX - w * 1.3} ${BASE + 0.5}H${CX + w * 1.3}`);\n  }\n}\n\nexport function Crumb({\n  fed,\n  total,\n  reduced,\n  overlay,\n  stage,\n  ref,\n}: {\n  /** Cookies eaten so far. */\n  fed: number;\n  /** Cookies there are to eat. */\n  total: number;\n  reduced: boolean;\n  /** The block-wide <svg> the cookies fly in. */\n  overlay: React.RefObject<SVGSVGElement | null>;\n  /** The stage the bun stands on, to place its mouth in block px. */\n  stage: React.RefObject<HTMLElement | null>;\n  ref?: React.Ref<CrumbHandle>;\n}) {\n  const [engine] = React.useState(() => new CrumbEngine());\n  const uid = React.useId().replace(/[^a-z0-9]/gi, \"\");\n\n  React.useLayoutEffect(() => {\n    engine.mount(overlay, stage, reduced, fed, total);\n    return () => engine.destroy();\n    // mounted once per reduced-motion setting; fed/total arrive through setTarget below\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [engine, overlay, stage, reduced]);\n\n  React.useEffect(() => {\n    engine.setTarget(fed, total);\n  }, [engine, fed, total]);\n\n  React.useImperativeHandle(ref, () => ({ feed: (p, d = 0) => engine.fly(1, p, d), giveBack: (p, d = 0) => engine.fly(-1, p, d), wave: () => engine.wave() }), [engine]);\n\n  const dough = `url(#${uid}-dough)`;\n  const line = { stroke: \"var(--crumb-line)\", strokeWidth: 1.5 };\n  return (\n    <svg viewBox={`0 0 ${VB.w} ${VB.h}`} className=\"absolute inset-0 size-full\" aria-hidden=\"true\">\n      <defs>\n        {/* the dough is lit from its top left, like everything else on the stage */}\n        <radialGradient id={`${uid}-dough`} cx=\"0.36\" cy=\"0.26\" r=\"0.85\">\n          <stop offset=\"0\" style={{ stopColor: \"var(--crumb-hi)\" }} />\n          <stop offset=\"0.55\" style={{ stopColor: \"var(--crumb-body)\" }} />\n          <stop offset=\"1\" style={{ stopColor: \"var(--crumb-edge)\" }} />\n        </radialGradient>\n      </defs>\n      <path ref={engine.part(\"floor\")} stroke=\"var(--crumb-floor)\" strokeWidth=\"1\" fill=\"none\" />\n      <g ref={engine.part(\"bun\")}>\n        <ellipse ref={engine.part(\"footL\")} cy={BASE - 1} rx=\"10\" ry=\"4.5\" fill=\"var(--crumb-shade)\" />\n        <ellipse ref={engine.part(\"footR\")} cy={BASE - 1} rx=\"10\" ry=\"4.5\" fill=\"var(--crumb-shade)\" />\n        <g ref={engine.part(\"nubL\")}>\n          <ellipse rx=\"10\" ry=\"8\" fill={dough} {...line} />\n        </g>\n        <g ref={engine.part(\"nubR\")}>\n          <ellipse rx=\"10\" ry=\"8\" fill={dough} {...line} />\n        </g>\n        <path ref={engine.part(\"body\")} fill={dough} stroke=\"var(--crumb-line)\" strokeWidth=\"1.6\" strokeLinejoin=\"round\" />\n        <path ref={engine.part(\"gloss\")} stroke=\"var(--crumb-hi)\" strokeWidth=\"3\" strokeLinecap=\"round\" fill=\"none\" opacity=\"0.85\" />\n        <ellipse ref={engine.part(\"cheekL\")} rx=\"7\" ry=\"4.5\" fill=\"var(--crumb-blush)\" />\n        <ellipse ref={engine.part(\"cheekR\")} rx=\"7\" ry=\"4.5\" fill=\"var(--crumb-blush)\" />\n        {([\"L\", \"R\"] as const).map((side) => (\n          <React.Fragment key={side}>\n            <g ref={engine.part(`eye${side}`)}>\n              <ellipse rx=\"5.6\" ry=\"7\" fill=\"var(--crumb-ink)\" />\n              <circle cx=\"1.9\" cy=\"-2.6\" r=\"1.9\" fill=\"var(--crumb-hi)\" />\n              <circle ref={engine.part(`glint${side}`)} cx=\"-1.8\" cy=\"2.4\" r=\"1\" fill=\"var(--crumb-hi)\" style={{ opacity: 0 }} />\n            </g>\n            <path ref={engine.part(`happy${side}`)} d=\"M-6 2 Q0 -6 6 2\" stroke=\"var(--crumb-ink)\" strokeWidth=\"2.4\" fill=\"none\" strokeLinecap=\"round\" style={{ opacity: 0 }} />\n            <path ref={engine.part(`brow${side}`)} d=\"M-5 0 H5\" stroke=\"var(--crumb-ink)\" strokeWidth=\"2\" strokeLinecap=\"round\" />\n          </React.Fragment>\n        ))}\n        <path ref={engine.part(\"mouth\")} stroke=\"var(--crumb-ink)\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n        {/* arms in front, so they can fold round a cookie */}\n        <g ref={engine.part(\"armL\")}>\n          <ellipse cy=\"-8\" rx=\"7\" ry=\"11\" fill={dough} {...line} />\n        </g>\n        <g ref={engine.part(\"armR\")}>\n          <ellipse cy=\"-8\" rx=\"7\" ry=\"11\" fill={dough} {...line} />\n        </g>\n      </g>\n      <g ref={engine.part(\"sparkles\")} style={{ opacity: 0 }} fill=\"var(--crumb-spark)\">\n        {[\n          [-48, 10, 1],\n          [46, -2, 0.8],\n          [-30, -14, 0.6],\n          [58, 24, 0.55],\n        ].map(([x, y, s], i) => (\n          <path key={i} transform={`translate(${x} ${y}) scale(${s})`} d=\"M0 -7 Q1 -1 7 0 Q1 1 0 7 Q-1 1 -7 0 Q-1 -1 0 -7Z\" />\n        ))}\n      </g>\n    </svg>\n  );\n}\n\nexport default Crumb;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · cookie-consent · 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":{},"docs":"Docs and live preview: https://hairlineui.com/ui/cookie-consent. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}