# Cookie consent

> A cookie banner with a mascot: every cookie you allow flies to Crumb, who catches it, nibbles it and gets rounder and happier.

A shadcn/ui block from the [@hairline](https://hairlineui.com/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hairline/cookie-consent).

- Page, with a live preview: https://shadcn.exchange/r/hairline/cookie-consent
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hairline/cookie-consent-light-eb3cadf778cc7f0b.png
- Registry item: https://hairlineui.com/r/cookie-consent.json
- Listed: 2026-10-21, source last changed 2026-10-21

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/hairline/cookie-consent.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `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`

## Notes from the registry

Docs and live preview: https://hairlineui.com/ui/cookie-consent. MIT licensed: https://hairlineui.com/license#components

## Files

### `registry/cookie-consent.tsx`

```tsx
// Hairline UI · cookie-consent · MIT License
// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components

"use client";

import * as React from "react";
import { Crumb, type CrumbHandle, type Point } from "./cookie-crumb";
import { StatusButton, type ActionStatus } from "./status-button";
import { Switch } from "./switch";
import { TextMorph } from "./text-morph";
import { useInteractionReducedMotion } from "./ui-interaction-motion";

/* ─────────────────────────────────────────────────────────
 * COOKIE CONSENT: every cookie you allow goes to Crumb
 *
 *   ask       Crumb, a dough bun, sits on the lit stage looking
 *             at the switches, hungry; Necessary is always on
 *   allow     switch a category on and a cookie leaps from the
 *             switch into Crumb's mouth: it watches it come,
 *             opens up, chomps, crumbs fly, and it gets rounder
 *             and happier; with everything allowed it's stuffed,
 *             eyes squeezed shut, sparkling
 *   take back switch one off and Crumb hands the cookie back,
 *             with a bite out of it, and shrinks a little
 *   decide    Reject all hands every cookie back; Accept all
 *             feeds the rest; the main button says Accept all or
 *             Save choices, and saving waves goodbye
 *
 * The choice is yours to keep: onSave gets { [category]: on }
 * to write to a cookie, your consent platform or your API.
 * ───────────────────────────────────────────────────────── */

export interface CookieCategory {
  id: string;
  label: string;
  description?: string;
  /** Always on and can't be switched off, e.g. sign-in and security. */
  required?: boolean;
}

export type CookieChoices = Record<string, boolean>;

export interface CookieConsentProps {
  categories?: CookieCategory[];
  value?: CookieChoices;
  defaultValue?: CookieChoices;
  onValueChange?: (choices: CookieChoices) => void;
  /** Keep the choices (a cookie, your consent platform, your API); throw an Error to say why not. */
  onSave: (choices: CookieChoices) => Promise<unknown>;
  title?: string;
  description?: string;
  /** Your cookie policy, linked under the choices. */
  policyHref?: string;
  /** What the mascot is called in its lines. */
  name?: string;
  className?: string;
}

const CATEGORIES: CookieCategory[] = [
  { id: "necessary", label: "Necessary", description: "Sign-in, security and your choices here.", required: true },
  { id: "analytics", label: "Analytics", description: "Counts visits so we know what to fix." },
  { id: "marketing", label: "Marketing", description: "Measures ads and where you came from." },
  { id: "personalization", label: "Personalization", description: "Remembers what you like." },
];
const EMPTY: CookieChoices = {};

const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;
const CARD: React.CSSProperties = {
  backgroundImage: `linear-gradient(180deg, var(--card), ${mix("var(--card)", 95, "var(--foreground)")})`,
  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix("var(--background)", 85)}`,
};
const STAGE: React.CSSProperties = {
  backgroundColor: "var(--primary)",
  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%)`,
  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 36)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 12)}`,
};
const LIT: React.CSSProperties = {
  backgroundImage: `linear-gradient(180deg, ${mix("var(--primary-foreground)", 16)}, transparent 60%)`,
  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 32)}, inset 0 0 0 1px ${mix("black", 10)}`,
};
/* Crumb's colours: the bun takes on your theme; the cookie, blush and sparkles are its own, and each can be overridden. */
const CRUMB = {
  "--crumb-hi": "var(--primary-foreground)",
  "--crumb-body": mix("var(--primary-foreground)", 93, "var(--primary)"),
  "--crumb-edge": mix("var(--primary-foreground)", 76, "var(--primary)"),
  "--crumb-line": mix("var(--primary)", 55, "black"),
  "--crumb-shade": mix("var(--primary)", 40, "var(--primary-foreground)"),
  "--crumb-ink": mix("var(--primary)", 30, "black"),
  "--crumb-floor": mix("var(--primary-foreground)", 40),
  "--crumb-blush": "oklch(0.78 0.12 12)",
  "--crumb-spark": "var(--primary-foreground)",
  "--crumb-cookie": "oklch(0.74 0.1 70)",
  "--crumb-cookie-rim": "oklch(0.6 0.1 58)",
  "--crumb-chip": "oklch(0.36 0.05 45)",
} as React.CSSProperties;

export function CookieConsent({
  categories = CATEGORIES,
  value,
  defaultValue = EMPTY,
  onValueChange,
  onSave,
  title = "Cookie settings",
  description,
  policyHref,
  name = "Crumb",
  className = "",
}: CookieConsentProps) {
  const reduced = useInteractionReducedMotion();
  const baseId = React.useId();
  const [own, setOwn] = React.useState<CookieChoices>(defaultValue);
  const choices = value ?? own;
  const [act, setAct] = React.useState<{ which: "accept" | "reject" | null; status: ActionStatus; error?: string }>({ which: null, status: "idle" });
  const [said, setSaid] = React.useState("");
  const sectionRef = React.useRef<HTMLElement>(null);
  const overlayRef = React.useRef<SVGSVGElement>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const crumb = React.useRef<CrumbHandle>(null);
  const switches = React.useRef(new Map<string, HTMLElement>());

  const optional = categories.filter((c) => !c.required);
  const isOn = (c: CookieCategory) => Boolean(c.required || choices[c.id]);
  const fed = optional.filter(isOn).length;
  const allOn = fed === optional.length;
  const busy = act.status === "pending";

  const commit = (next: CookieChoices) => {
    if (value === undefined) setOwn(next);
    onValueChange?.(next);
  };

  /** Where a category's switch is, in the block's own px. */
  const switchAt = (id: string): Point => {
    const section = sectionRef.current;
    const el = switches.current.get(id);
    if (!section || !el) return { x: 0, y: 0 };
    const box = section.getBoundingClientRect();
    const k = box.width / (section.offsetWidth || box.width) || 1;
    const r = el.getBoundingClientRect();
    return { x: (r.left + r.width / 2 - box.left) / k, y: (r.top + r.height / 2 - box.top) / k };
  };

  const toggle = (c: CookieCategory, on: boolean) => {
    if (busy || c.required) return;
    commit({ ...choices, [c.id]: on });
    if (on) crumb.current?.feed(switchAt(c.id));
    else crumb.current?.giveBack(switchAt(c.id));
    setSaid(`${c.label} ${on ? "allowed" : "off"}.`);
    if (act.status !== "idle") setAct({ which: null, status: "idle" });
  };

  const save = async (which: "accept" | "reject", next: CookieChoices, wait: number) => {
    setAct({ which, status: "pending" });
    try {
      // let the cookies land before the choice is kept
      await new Promise((r) => window.setTimeout(r, wait));
      await onSave(next);
      setAct({ which, status: "success" });
      setSaid(which === "reject" ? "Saved: only necessary cookies." : `Saved your cookie choices.`);
      crumb.current?.wave();
    } catch (e) {
      setAct({ which, status: "error", error: e instanceof Error && e.message ? e.message : "Couldn’t save that. Try again." });
    }
  };

  const acceptAll = () => {
    if (busy) return;
    const off = optional.filter((c) => !choices[c.id]);
    const keep = fed > 0 && !allOn;
    if (keep) return void save("accept", { ...choices, ...Object.fromEntries(categories.filter((c) => c.required).map((c) => [c.id, true])) }, 0);
    const next = Object.fromEntries(categories.map((c) => [c.id, true]));
    commit(next);
    off.forEach((c, i) => crumb.current?.feed(switchAt(c.id), i * 170));
    void save("accept", next, reduced || !off.length ? 0 : off.length * 170 + 620);
  };

  const rejectAll = () => {
    if (busy) return;
    const on = optional.filter((c) => choices[c.id]);
    const next = Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required)]));
    commit(next);
    on.forEach((c, i) => crumb.current?.giveBack(switchAt(c.id), i * 140));
    void save("reject", next, reduced || !on.length ? 0 : on.length * 140 + 640);
  };

  const mood = fed === 0 ? `${name} is hungry` : allOn ? `${name} is stuffed` : `${name} could eat more`;
  const mainLabel = fed > 0 && !allOn ? "Save choices" : "Accept all";

  return (
    <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 }}>
      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}
      <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 380px)" }} />
      <div className="@container w-full">
        {/* The one lit surface: Crumb's stage. */}
        <div ref={stageRef} className="relative h-[172px] overflow-clip rounded-[22px] text-primary-foreground" style={STAGE}>
          <p className="absolute left-3.5 top-3 z-10 text-[12.5px] font-medium text-primary-foreground/80">
            <TextMorph>{mood}</TextMorph>
          </p>
          <Crumb ref={crumb} fed={fed} total={optional.length} reduced={reduced} overlay={overlayRef} stage={stageRef} />
        </div>

        <div className="px-2 pt-3">
          <h2 id={`${baseId}-title`} className="px-1 text-[15px] font-medium tracking-[-0.01em]">
            {title}
          </h2>
          <p className="mt-0.5 px-1 text-[12.5px] text-muted-foreground">{description ?? `Every cookie you allow goes to ${name}.`}</p>

          <ul className="mt-2">
            {categories.map((c) => (
              <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">
                <span className="min-w-0 flex-1">
                  <span id={`${baseId}-${c.id}`} className="block text-[13.5px] font-medium">
                    {c.label}
                  </span>
                  {c.description && <span className="block truncate text-[12px] text-muted-foreground">{c.description}</span>}
                </span>
                {c.required ? (
                  <span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11.5px] text-muted-foreground">Always on</span>
                ) : (
                  <span
                    ref={(el) => {
                      if (el) switches.current.set(c.id, el);
                      else switches.current.delete(c.id);
                    }}
                    className="flex shrink-0"
                  >
                    <Switch size="sm" checked={isOn(c)} aria-labelledby={`${baseId}-${c.id}`} disabled={busy} onCheckedChange={(on) => toggle(c, on)} />
                  </span>
                )}
              </li>
            ))}
          </ul>

          <div className="mt-2.5 grid grid-cols-2 gap-2">
            <StatusButton
              variant="outline"
              status={act.which === "reject" ? act.status : "idle"}
              labels={{ idle: "Reject all", pending: "Saving", success: "Saved", error: "Try again" }}
              disabled={busy && act.which !== "reject"}
              onClick={rejectAll}
              className="h-10 w-full text-[13.5px]"
            />
            <StatusButton
              status={act.which === "accept" ? act.status : "idle"}
              labels={{ idle: mainLabel, pending: "Saving", success: "Saved", error: "Try again" }}
              disabled={busy && act.which !== "accept"}
              onClick={acceptAll}
              className="h-10 w-full text-[13.5px]"
              style={act.which === "accept" && act.status === "error" ? undefined : LIT}
            />
          </div>
          <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"}`}>
            {act.error ?? (
              <>
                Change this any time{policyHref ? " · " : "."}
                {policyHref && (
                  <a href={policyHref} className="underline decoration-border underline-offset-2 hover:text-foreground">
                    Cookie policy
                  </a>
                )}
              </>
            )}
          </p>
        </div>
      </div>

      {/* Cookies fly over the whole block, from a switch to the mouth and back. */}
      <svg ref={overlayRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20 size-full overflow-visible" />

      <span className="sr-only" role="status">
        {said}
      </span>
    </section>
  );
}

export default CookieConsent;
```

### `registry/cookie-crumb.tsx`

```tsx
// Hairline UI · cookie-consent · MIT License
// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components

"use client";

import * as React from "react";

/* ─────────────────────────────────────────────────────────
 * CRUMB: a dough bun that eats the cookies you allow
 *
 * Drawn in SVG and moved by one frame loop that sleeps when
 * nothing moves. Its face is continuous, not swapped pictures:
 * how full it is (cookies eaten / cookies there are) sets its
 * width, smile, blush and, once it has everything, its happy
 * eyes and sparkles.
 *
 *   flight   a cookie arcs from a switch; Crumb watches it come
 *            and opens up as it gets close
 *   nibble   it catches the cookie in both arms and takes three
 *            bites, a chunk out of the cookie and crumbs each
 *            time, then swallows and fills out
 *   give     handing one back it pouts and lets go of a cookie
 *            with a bite out of it, which arcs back to its switch
 *
 * Cookies fly in an <svg> overlay that covers the whole block,
 * so they can travel from a switch to the mouth; positions are
 * in the block's own px. The engine lives outside React and
 * writes the SVG directly, so a flight costs no renders.
 * ───────────────────────────────────────────────────────── */

export type Point = { x: number; y: number };

export interface CrumbHandle {
  /** A cookie flies from `from` (block px) into the mouth. */
  feed: (from: Point, delay?: number) => void;
  /** A bitten cookie flies from the mouth back to `to` (block px). */
  giveBack: (to: Point, delay?: number) => void;
  /** A happy wave, e.g. once the choices are saved. */
  wave: () => void;
}

const VB = { w: 200, h: 150 };
const BASE = 138; // the floor, in the stage's own units
const CX = 100;
const NS = "http://www.w3.org/2000/svg";
const HOLD = 860; // ms the cookie is held and nibbled
const BITES = [150, 430, 710];
const SIZE = 1.3; // cookies at the bun's scale

const ease = (k: number) => 1 - Math.pow(1 - k, 3);
const inOut = (k: number) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const lerp = (a: number, b: number, k: number) => a + (b - a) * k;

type Spring = { x: number; v: number };
/** One step of a spring toward its target; true once it has settled. */
function step(s: Spring, target: number, dt: number, k = 170, c = 18) {
  s.v += ((target - s.x) * k - s.v * c) * dt;
  s.x += s.v * dt;
  return Math.abs(target - s.x) < 1e-3 && Math.abs(s.v) < 1e-3;
}

/** The bun's outline: a dome with a soft, flat bottom, wider as it fills. */
function bodyPath(w: number, h: number) {
  const top = BASE - h;
  const mid = BASE - h * 0.46;
  return (
    `M${CX - w * 0.84} ${BASE}` +
    `C${CX - w * 1.03} ${BASE} ${CX - w * 1.05} ${mid + h * 0.17} ${CX - w} ${mid}` +
    `C${CX - w * 0.95} ${top + h * 0.1} ${CX - w * 0.54} ${top} ${CX} ${top}` +
    `C${CX + w * 0.54} ${top} ${CX + w * 0.95} ${top + h * 0.1} ${CX + w} ${mid}` +
    `C${CX + w * 1.05} ${mid + h * 0.17} ${CX + w * 1.03} ${BASE} ${CX + w * 0.84} ${BASE}Z`
  );
}

/** A cookie as SVG markup: a disc, a darker rim and chips, and three bites hidden in its mask. */
const COOKIE = (id: string, bitten: number) =>
  `<defs><mask id="${id}"><rect x="-14" y="-14" width="28" height="28" fill="white"/>` +
  [
    [9, -8, 6, 12.5, -2, 4.5],
    [-9.5, -7.5, 5.5, -4, -12, 4.5],
    [1, 11.5, 6, 8, 9, 4],
  ]
    .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>`)
    .join("") +
  `</mask></defs><g mask="url(#${id})"><circle r="11" fill="var(--crumb-cookie)" stroke="var(--crumb-cookie-rim)" stroke-width="1.4"/>` +
  `<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)"/>` +
  `<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>`;

type Flight = { id: number; dir: 1 | -1; a: Point; b: Point; t0: number; dur: number; el: SVGGElement; held: number; bites: number };
type Speck = { x: number; y: number; vx: number; vy: number; t0: number; el: SVGCircleElement };

export class CrumbEngine {
  parts: Record<string, SVGElement | null> = {};
  overlayRef: React.RefObject<SVGSVGElement | null> | null = null;
  stageRef: React.RefObject<HTMLElement | null> | null = null;
  reduced = false;
  target = 0;
  full: Spring = { x: 0, v: 0 };
  joy: Spring = { x: 0, v: 0 };
  squash: Spring = { x: 0, v: 0 };
  open: Spring = { x: 0, v: 0 };
  arms: Spring = { x: 0, v: 0 };
  hug: Spring = { x: 0, v: 0 };
  pout: Spring = { x: 0, v: 0 };
  lookX: Spring = { x: 0, v: 0 };
  lookY: Spring = { x: 1, v: 0 };
  blinkAt = 0;
  chewUntil = 0;
  waveUntil = 0;
  flights: Flight[] = [];
  specks: Speck[] = [];
  seq = 0;
  raf = 0;
  last = 0;
  blinkTimer = 0;
  frame = (now: number) => this.tick(now);

  get overlay() {
    return this.overlayRef?.current ?? null;
  }
  get stage() {
    return this.stageRef?.current ?? null;
  }

  part = (name: string) => (el: SVGElement | null) => {
    this.parts[name] = el;
  };

  mount(overlay: React.RefObject<SVGSVGElement | null>, stage: React.RefObject<HTMLElement | null>, reduced: boolean, fed: number, total: number) {
    this.overlayRef = overlay;
    this.stageRef = stage;
    this.reduced = reduced;
    this.target = total ? clamp(fed / total) : 0;
    this.full.x = this.joy.x = this.target;
    this.draw(performance.now());
    this.scheduleBlink();
  }

  destroy() {
    cancelAnimationFrame(this.raf);
    window.clearTimeout(this.blinkTimer);
    this.raf = 0;
    this.flights.forEach((f) => f.el.remove());
    this.specks.forEach((s) => s.el.remove());
    this.flights = [];
    this.specks = [];
  }

  setTarget(fed: number, total: number) {
    this.target = total ? clamp(fed / total) : 0;
    if (this.reduced) {
      this.full.x = this.joy.x = this.target;
      this.draw(performance.now());
      return;
    }
    this.wake();
  }

  scheduleBlink() {
    if (this.reduced) return;
    window.clearTimeout(this.blinkTimer);
    this.blinkTimer = window.setTimeout(() => {
      this.blinkAt = performance.now();
      this.wake();
      this.scheduleBlink();
    }, 2400 + ((this.seq * 977 + 311) % 1900));
  }

  wake() {
    if (this.raf || !this.parts.body) return;
    this.last = performance.now();
    this.raf = requestAnimationFrame(this.frame);
  }

  /** The mouth in block px: the stage's place in the block (layout offsets ignore transforms) plus the bun's face. */
  mouth(): Point {
    const host = this.stage;
    const root = this.overlay?.parentElement;
    if (!host || !root) return { x: 0, y: 0 };
    let x = 0, y = 0;
    for (let n: HTMLElement | null = host; n && n !== root; n = n.offsetParent as HTMLElement | null) {
      x += n.offsetLeft;
      y += n.offsetTop;
    }
    const k = Math.min(host.clientWidth / VB.w, host.clientHeight / VB.h);
    const h = 84 + 4 * this.full.x;
    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 };
  }

  fly(dir: 1 | -1, p: Point, delay: number) {
    const overlay = this.overlay;
    if (this.reduced || !overlay) return;
    const id = ++this.seq;
    const el = document.createElementNS(NS, "g") as SVGGElement;
    el.innerHTML = COOKIE(`crumb-cookie-${id}-${Math.round(p.x)}-${Math.round(p.y)}`, dir === -1 ? 1 : 0);
    el.style.opacity = "0";
    overlay.appendChild(el);
    const m = this.mouth();
    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 });
    if (dir === -1) window.setTimeout(() => (this.pout.v += 7), delay);
    this.wake();
  }

  wave() {
    if (this.reduced) return;
    this.waveUntil = performance.now() + 1300;
    this.wake();
  }

  crumbs(at: Point, count: number, seed: number, now: number) {
    const overlay = this.overlay;
    if (!overlay) return;
    for (let i = 0; i < count; i++) {
      const a = -Math.PI * (0.1 + 0.8 * ((i * 0.37 + seed * 0.13) % 1));
      const sp = 45 + ((i * 53 + seed * 31) % 60);
      const el = document.createElementNS(NS, "circle") as SVGCircleElement;
      el.setAttribute("fill", "var(--crumb-cookie)");
      el.setAttribute("r", String(1.2 + ((i * 7 + seed) % 3) * 0.5));
      overlay.appendChild(el);
      this.specks.push({ x: at.x, y: at.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, t0: now, el });
    }
  }

  tick(now: number) {
    const dt = Math.min(0.05, (now - this.last) / 1000);
    this.last = now;
    let busy = false;
    let near = 0;
    let holding = false;
    let watch: Point | null = null;

    for (const f of this.flights) {
      if (now < f.t0) {
        busy = true;
        continue;
      }
      busy = true;
      if (f.held) {
        // held at the mouth and nibbled: a bite at each mark, a chunk out of the cookie, crumbs, a snap of the jaw
        holding = true;
        const t = now - f.held;
        while (f.bites < BITES.length && t >= BITES[f.bites]) {
          f.el.querySelector<SVGGElement>(`[data-bite="${f.bites}"]`)?.style.setProperty("transform", "scale(1)");
          f.bites++;
          this.open.v -= 11;
          this.squash.v += 5;
          this.chewUntil = now + 260;
          this.crumbs({ x: f.b.x + (f.bites % 2 ? 7 : -7), y: f.b.y - 2 }, 4, f.id + f.bites, now);
        }
        const bob = Math.sin(t / 55) * 0.8;
        f.el.setAttribute("transform", `translate(${f.b.x} ${f.b.y + bob}) rotate(${-8 + f.bites * 6}) scale(${(1 - f.bites * 0.08) * SIZE})`);
        f.el.style.opacity = String(1 - clamp((t - HOLD + 120) / 120));
        if (t >= HOLD) {
          f.el.remove();
          this.chewUntil = now + 520;
          this.squash.v += 6;
        }
        continue;
      }
      const k = clamp((now - f.t0) / f.dur);
      const e = f.dir === 1 ? ease(k) : inOut(k);
      const mx = (f.a.x + f.b.x) / 2;
      const my = Math.min(f.a.y, f.b.y) - (f.dir === 1 ? 74 : 58);
      const x = (1 - e) * (1 - e) * f.a.x + 2 * (1 - e) * e * mx + e * e * f.b.x;
      const y = (1 - e) * (1 - e) * f.a.y + 2 * (1 - e) * e * my + e * e * f.b.y;
      const away = f.dir === -1 ? clamp((k - 0.86) / 0.14) : 0;
      const pop = (f.dir === 1 ? lerp(0.5, 1, clamp(k / 0.18)) : lerp(0.85, 1, k)) * SIZE;
      f.el.setAttribute("transform", `translate(${x} ${y}) rotate(${(f.dir === 1 ? 300 : -240) * e - (f.dir === 1 ? 8 : 0)}) scale(${pop})`);
      f.el.style.opacity = String(1 - away);
      if (f.dir === 1) {
        near = Math.max(near, clamp((k - 0.4) / 0.42));
        watch = { x, y };
      } else if (k < 0.65) watch = { x, y };
      if (k >= 1) {
        if (f.dir === 1) {
          f.held = now;
          this.squash.v += 7;
          this.open.v -= 6;
        } else f.el.remove();
      }
    }
    this.flights = this.flights.filter((f) => f.el.isConnected);

    for (const s of this.specks) {
      const t = (now - s.t0) / 1000;
      s.el.setAttribute("cx", String(s.x + s.vx * t));
      s.el.setAttribute("cy", String(s.y + s.vy * t + 320 * t * t));
      s.el.setAttribute("opacity", String(clamp(1 - t / 0.75)));
      if (t >= 0.75) s.el.remove();
    }
    this.specks = this.specks.filter((s) => s.el.isConnected);
    if (this.specks.length) busy = true;

    const chewing = now < this.chewUntil;
    const chew = chewing ? 0.3 * Math.max(0, Math.sin(((this.chewUntil - now) / 260) * Math.PI)) : 0;
    const waving = now < this.waveUntil;
    const m = this.mouth();
    const lx = watch ? clamp((watch.x - m.x) / 40, -1, 1) : 0;
    const ly = watch ? clamp((watch.y - (m.y - 18)) / 40, -1, 1) : holding ? 0.6 : this.target < 0.34 ? 0.9 : 0;
    const eating = this.flights.some((f) => f.dir === 1 && now >= f.t0);
    const settled = [
      step(this.full, eating ? this.full.x : this.target, dt, 120, 15),
      step(this.joy, this.target, dt, 90, 14),
      step(this.squash, 0, dt, 260, 13),
      step(this.open, holding ? 0.5 + chew : near + chew, dt, 320, 22),
      step(this.arms, near > 0.2 ? 1 : waving ? 0.55 : 0, dt, 200, 18),
      step(this.hug, holding ? 1 : 0, dt, 220, 19),
      step(this.pout, 0, dt, 60, 9),
      step(this.lookX, lx, dt, 240, 22),
      step(this.lookY, ly, dt, 240, 22),
    ].every(Boolean);
    if (!settled || chewing || waving) busy = true;
    if (this.blinkAt && now - this.blinkAt < 160) busy = true;
    else this.blinkAt = 0;

    this.draw(now);
    this.raf = busy ? requestAnimationFrame(this.frame) : 0;
  }

  draw(now: number) {
    const P = this.parts;
    const f = clamp(this.full.x);
    const j = clamp(this.joy.x);
    const open = clamp(this.open.x);
    const hug = clamp(this.hug.x);
    const sad = clamp(1 - j * 4) * (1 - open);
    const glee = clamp((j - 0.82) / 0.18) * (1 - open) * (1 - hug);
    const blink = this.blinkAt ? Math.sin(clamp((now - this.blinkAt) / 150) * Math.PI) : 0;
    const w = 48 + 15 * f;
    const h = 84 + 4 * f;
    const sq = this.squash.x * 0.012;
    P.bun?.setAttribute("transform", `translate(${CX} ${BASE}) scale(${1 + sq} ${1 - sq * 1.2}) translate(${-CX} ${-BASE})`);
    P.body?.setAttribute("d", bodyPath(w, h));
    const top = BASE - h;
    const fy = BASE - h * 0.5;
    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}`);
    P.nubL?.setAttribute("transform", `translate(${CX - 17} ${top + 5}) rotate(-14)`);
    P.nubR?.setAttribute("transform", `translate(${CX + 17} ${top + 5}) rotate(14)`);
    P.footL?.setAttribute("cx", String(CX - w * 0.42));
    P.footR?.setAttribute("cx", String(CX + w * 0.42));
    const wave = now < this.waveUntil ? Math.sin((this.waveUntil - now) / 85) * 20 : 0;
    const arm = this.arms.x * (1 - hug);
    // arms lift as a cookie comes, and fold in round it while it's held
    P.armL?.setAttribute("transform", `translate(${CX - w * 0.97 + 9 * hug} ${fy + 16 - 2 * hug}) rotate(${-14 - 72 * arm + 76 * hug})`);
    P.armR?.setAttribute("transform", `translate(${CX + w * 0.97 - 9 * hug} ${fy + 16 - 2 * hug}) rotate(${14 + 72 * arm - 76 * hug + wave})`);
    const ex = 15 + 2 * f;
    const lid = (0.8 + 0.2 * (1 - sad)) * (1 - blink) * (1 - glee);
    const lx = this.lookX.x * 2.6;
    const ly = this.lookY.x * 2.2;
    for (const [side, dir] of [["L", -1], ["R", 1]] as const) {
      const big = 1 + 0.16 * sad;
      P[`eye${side}`]?.setAttribute("transform", `translate(${CX + dir * ex + lx} ${fy - 4 + ly}) scale(${big} ${Math.max(0.06, lid) * big})`);
      const glint = P[`glint${side}`];
      if (glint) glint.style.opacity = String(sad);
      const happy = P[`happy${side}`];
      if (happy) {
        happy.setAttribute("transform", `translate(${CX + dir * ex} ${fy - 3})`);
        happy.style.opacity = String(glee);
      }
      const brow = P[`brow${side}`];
      if (brow) {
        brow.setAttribute("transform", `translate(${CX + dir * ex} ${fy - 14 + 2 * (1 - sad)}) rotate(${dir * 20 * sad})`);
        brow.style.opacity = String(sad);
      }
      const cheek = P[`cheek${side}`];
      if (cheek) {
        cheek.setAttribute("transform", `translate(${CX + dir * (ex + 11 + 4 * f)} ${fy + 7}) scale(${1 + 0.45 * f + 0.15 * hug})`);
        cheek.style.opacity = String(0.15 + 0.75 * j);
      }
    }
    const smile = lerp(-0.85, 1, Math.sqrt(j)) - this.pout.x * 0.12;
    const gape = open + clamp(j - 0.55) * 0.55 * (1 - open);
    const my = fy + 12;
    const mw = 7 + 2 * j + 2 * open;
    const mouth = P.mouth;
    if (mouth) {
      const curve = 6 * smile;
      const depth = 4 + 12 * gape;
      mouth.setAttribute(
        "d",
        gape > 0.05
          ? `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`
          : `M${CX - mw} ${my} Q${CX} ${my + curve} ${CX + mw} ${my}`,
      );
      mouth.setAttribute("fill", gape > 0.05 ? "var(--crumb-ink)" : "none");
    }
    const sp = P.sparkles;
    if (sp) {
      sp.style.opacity = String(glee);
      sp.setAttribute("transform", `translate(${CX} ${top + 4}) scale(${0.85 + 0.15 * glee})`);
    }
    P.floor?.setAttribute("d", `M${CX - w * 1.3} ${BASE + 0.5}H${CX + w * 1.3}`);
  }
}

export function Crumb({
  fed,
  total,
  reduced,
  overlay,
  stage,
  ref,
}: {
  /** Cookies eaten so far. */
  fed: number;
  /** Cookies there are to eat. */
  total: number;
  reduced: boolean;
  /** The block-wide <svg> the cookies fly in. */
  overlay: React.RefObject<SVGSVGElement | null>;
  /** The stage the bun stands on, to place its mouth in block px. */
  stage: React.RefObject<HTMLElement | null>;
  ref?: React.Ref<CrumbHandle>;
}) {
  const [engine] = React.useState(() => new CrumbEngine());
  const uid = React.useId().replace(/[^a-z0-9]/gi, "");

  React.useLayoutEffect(() => {
    engine.mount(overlay, stage, reduced, fed, total);
    return () => engine.destroy();
    // mounted once per reduced-motion setting; fed/total arrive through setTarget below
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [engine, overlay, stage, reduced]);

  React.useEffect(() => {
    engine.setTarget(fed, total);
  }, [engine, fed, total]);

  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]);

  const dough = `url(#${uid}-dough)`;
  const line = { stroke: "var(--crumb-line)", strokeWidth: 1.5 };
  return (
    <svg viewBox={`0 0 ${VB.w} ${VB.h}`} className="absolute inset-0 size-full" aria-hidden="true">
      <defs>
        {/* the dough is lit from its top left, like everything else on the stage */}
        <radialGradient id={`${uid}-dough`} cx="0.36" cy="0.26" r="0.85">
          <stop offset="0" style={{ stopColor: "var(--crumb-hi)" }} />
          <stop offset="0.55" style={{ stopColor: "var(--crumb-body)" }} />
          <stop offset="1" style={{ stopColor: "var(--crumb-edge)" }} />
        </radialGradient>
      </defs>
      <path ref={engine.part("floor")} stroke="var(--crumb-floor)" strokeWidth="1" fill="none" />
      <g ref={engine.part("bun")}>
        <ellipse ref={engine.part("footL")} cy={BASE - 1} rx="10" ry="4.5" fill="var(--crumb-shade)" />
        <ellipse ref={engine.part("footR")} cy={BASE - 1} rx="10" ry="4.5" fill="var(--crumb-shade)" />
        <g ref={engine.part("nubL")}>
          <ellipse rx="10" ry="8" fill={dough} {...line} />
        </g>
        <g ref={engine.part("nubR")}>
          <ellipse rx="10" ry="8" fill={dough} {...line} />
        </g>
        <path ref={engine.part("body")} fill={dough} stroke="var(--crumb-line)" strokeWidth="1.6" strokeLinejoin="round" />
        <path ref={engine.part("gloss")} stroke="var(--crumb-hi)" strokeWidth="3" strokeLinecap="round" fill="none" opacity="0.85" />
        <ellipse ref={engine.part("cheekL")} rx="7" ry="4.5" fill="var(--crumb-blush)" />
        <ellipse ref={engine.part("cheekR")} rx="7" ry="4.5" fill="var(--crumb-blush)" />
        {(["L", "R"] as const).map((side) => (
          <React.Fragment key={side}>
            <g ref={engine.part(`eye${side}`)}>
              <ellipse rx="5.6" ry="7" fill="var(--crumb-ink)" />
              <circle cx="1.9" cy="-2.6" r="1.9" fill="var(--crumb-hi)" />
              <circle ref={engine.part(`glint${side}`)} cx="-1.8" cy="2.4" r="1" fill="var(--crumb-hi)" style={{ opacity: 0 }} />
            </g>
            <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 }} />
            <path ref={engine.part(`brow${side}`)} d="M-5 0 H5" stroke="var(--crumb-ink)" strokeWidth="2" strokeLinecap="round" />
          </React.Fragment>
        ))}
        <path ref={engine.part("mouth")} stroke="var(--crumb-ink)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
        {/* arms in front, so they can fold round a cookie */}
        <g ref={engine.part("armL")}>
          <ellipse cy="-8" rx="7" ry="11" fill={dough} {...line} />
        </g>
        <g ref={engine.part("armR")}>
          <ellipse cy="-8" rx="7" ry="11" fill={dough} {...line} />
        </g>
      </g>
      <g ref={engine.part("sparkles")} style={{ opacity: 0 }} fill="var(--crumb-spark)">
        {[
          [-48, 10, 1],
          [46, -2, 0.8],
          [-30, -14, 0.6],
          [58, 24, 0.55],
        ].map(([x, y, s], i) => (
          <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" />
        ))}
      </g>
    </svg>
  );
}

export default Crumb;
```

### `registry/ui-interaction-motion.ts`

```ts
// Hairline UI · cookie-consent · MIT License
// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components

'use client';

import * as React from 'react';

export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';
export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';
/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */
export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';
/**
 * The press every button primitive shares: a 3% give that eases in and settles back.
 * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list
 * or the press snaps.
 */
export const INTERACTION_PRESS =
  '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';
export 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';

const query = '(prefers-reduced-motion: reduce)';
const subscribe = (notify: () => void) => {
  const media = window.matchMedia(query);
  media.addEventListener('change', notify);
  return () => media.removeEventListener('change', notify);
};
export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);

export function useInteractionActivity<T extends Element>() {
  const ref = React.useRef<T>(null);
  const [visible, setVisible] = React.useState(false);
  const [hidden, setHidden] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
    observer.observe(el);
    const visibility = () => setHidden(document.hidden);
    visibility(); document.addEventListener('visibilitychange', visibility);
    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };
  }, []);
  return { ref, active: visible && !hidden };
}

export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {
  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}` };
}

/** Sample recent movement rather than averaging a long drag and losing its release velocity. */
export type PointerSample = { x: number; time: number };
export function samplePointer(samples: PointerSample[], x: number, time: number) {
  samples.push({ x, time });
  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();
}
export function releaseVelocity(samples: PointerSample[], now: number) {
  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;
  const first = samples[0], last = samples[samples.length - 1];
  return (last.x - first.x) / Math.max(1, last.time - first.time);
}

/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */
export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {
  const d = dimension || 320;
  const abs = Math.abs(overshoot);
  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;
}

/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */
export function projectInertia(distance: number, velocity: number, time = 0.12): number {
  return distance + velocity * 1000 * time;
}

/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */
export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {
  const maxAllowed = Math.abs(distanceToTarget) * factor;
  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);
}

/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */
export function springFrames(
  from: number,
  to: number,
  velocity = 0,
  duration = 480,
  config: { omega?: number; damping?: number; steps?: number } = {},
) {
  const { omega = 16, damping = 0.74, steps = 36 } = config;
  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));
  const displacement = from - to;
  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;
  return Array.from({ length: steps + 1 }, (_, i) => {
    const t = (i / steps) * (duration / 1000);
    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));
    return { value: i === steps ? to : value, offset: i / steps };
  });
}
```
