Components

Getting started

Blocks14

Components154

More

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

Used forCookie bannersPrivacy settingsPlayful brandsLaunch pages

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/cookie-consent.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Cookie banners

    The first-visit consent card, in a corner or centred, with a mascot people play with.

  • Privacy settings

    The cookie preferences page people come back to, with the same choices.

  • Playful brands

    Consumer apps, games and shops whose voice is warmer than a legal notice.

  • Launch pages

    A consent card that gets shared instead of skipped.

What it handles for you

A cookie banner is usually a paragraph, a Customise link and a modal of switches nobody reads. This block keeps the categories on one card, makes rejecting as easy as accepting, and gives every choice a reaction.

Instead of wiring upa cookie bannera preferences modalcategory switchesaccept and reject buttonsa consent save call
  1. A reaction to every choice

    Each category switched on sends Crumb a cookie; switched off, it hands the cookie back with a bite out of it.

  2. A mood you can read

    Crumb gets rounder and happier with each cookie: hungry, content, then stuffed and sparkling.

  3. Reject as easy as Accept

    Reject all sits beside the main button at the same size, and nothing optional is on until someone says so.

  4. Necessary, plainly

    Required categories show Always on and can’t be switched off.

  5. The right button

    The main button reads Accept all, or Save choices once some but not all are on; both go Saving → Saved.

  6. Kept your way

    onSave gets { [category]: on } for a cookie, your consent platform or your API; a failure shakes and says why.

Overview

Cookie banners are the most skipped screen on the web. This one gives the choice a face. Crumb, a little dough bun drawn in code, sits on the lit stage looking at the switches, hungry. Switch Analytics on and a cookie leaps from the switch: Crumb watches it come, opens up, catches it in both arms and takes three bites, a chunk out of the cookie and a spray of crumbs each time, then swallows and fills out a little, its smile growing and its cheeks flushing. With everything allowed it is stuffed, eyes squeezed shut, sparkling. Switch one back off and Crumb hands the cookie back, with a bite out of it, and shrinks. Reject all hands every cookie back; Accept all feeds the rest. Under the fun it is a proper consent banner: Necessary is always on and says so, Reject all sits beside the main button at the same size, the main button reads Accept all or Save choices depending on what you picked, and onSave gets the choices to keep wherever you keep them.

States

hungry
Crumb sits on the lit stage with big, pleading eyes looking down at the switches, brows lifted, a small frown; the line above reads “Crumb is hungry”. It blinks now and then.
feed
A switch on: a cookie pops out of it and arcs over the card (600ms); Crumb’s eyes follow it, its arms lift and its mouth opens as it comes close.
nibble
It catches the cookie in both arms and takes three bites over 860ms, each one cutting a chunk out of the cookie, snapping the jaw and throwing crumbs; then it swallows, squashes and fills out.
content / stuffed
Each cookie widens the bun, lifts the smile and deepens the blush; with every category on its eyes squeeze shut and sparkles appear, and the line reads “Crumb is stuffed”.
give back
A switch off: Crumb pouts and a cookie with a bite out of it arcs back to the switch; the bun shrinks and the smile drops.
reject / accept all
Reject all hands every cookie back, 140ms apart; Accept all feeds the rest, 170ms apart; the choices are saved once they have landed.
saved
The button goes Saving → Saved with a drawn check and Crumb waves.
not saved
If onSave throws, the button shakes into Try again and your message replaces the line under it.

Usage

usage.tsx
import { CookieConsent } from "@/components/cookie-consent";<CookieConsent  onSave={async (choices) => {    document.cookie = `consent=${encodeURIComponent(JSON.stringify(choices))}; max-age=31536000; path=/; samesite=lax`;  }}/>

Works with

Show it once, then load scripts by consent

Show the block until there is a consent cookie, keep the choices in it, and only load analytics and marketing scripts for the categories that are on.

example.tsx
"use client";import { useEffect, useState } from "react";import { CookieConsent, type CookieChoices } from "@/components/cookie-consent";const read = (): CookieChoices | null => {  const m = document.cookie.match(/(?:^|; )consent=([^;]*)/);  return m ? JSON.parse(decodeURIComponent(m[1])) : null;};function load(choices: CookieChoices) {  if (choices.analytics) import("@/lib/analytics").then((a) => a.start());  if (choices.marketing) import("@/lib/pixels").then((p) => p.start());}export function Consent() {  const [open, setOpen] = useState(false);  useEffect(() => {    const saved = read();    if (saved) load(saved);    else setOpen(true);  }, []);  if (!open) return null;  return (    <div className="fixed bottom-4 left-4 z-50 w-[min(380px,calc(100vw-2rem))]">      <CookieConsent        policyHref="/cookies"        onSave={async (choices) => {          document.cookie = `consent=${encodeURIComponent(JSON.stringify(choices))}; max-age=31536000; path=/; samesite=lax`;          load(choices);          setTimeout(() => setOpen(false), 1400); // after Crumb's wave        }}      />    </div>  );}

Props

PropTypeDescription
onSave(choices: CookieChoices) => Promise<unknown>Gets { [category id]: boolean } with required categories true. Keep it; throw an Error to show its message.
categoriesCookieCategory[]default Necessary, Analytics, Marketing, Personalization{ id, label, description?, required? }. Required ones show Always on; each other one is a cookie for Crumb.
value / defaultValue / onValueChangeCookieChoicesdefault — / {} (all optional off)The choices, controlled or not.
title / descriptionstringdefault "Cookie settings" / "Every cookie you allow goes to Crumb."The heading and the line under it.
namestringdefault "Crumb"What the mascot is called in its lines.
policyHrefstringLinks “Cookie policy” under the buttons.

Accessibility and motion

  • Each category is a real switch labelled by its name; the mascot is decoration (aria-hidden) and each change is announced from a status region (“Analytics allowed.”).
  • Nothing optional is on by default, and Reject all is the same size as the main button, side by side.
  • Crumb is SVG driven by one frame loop that sleeps when it’s still; cookies fly in an overlay over the card and cost no React renders.
  • Crumb takes on your theme: the dough is mixed from --primary-foreground and --primary, the stage is the lit --primary surface. The cookie, blush and chips are its own colours as --crumb-* variables you can override. No drop shadows, glow or blur.
  • Built from Switch, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion nothing flies and Crumb’s mood changes in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Status button, Switch, Text morph.

Source

components/cookie-consent.tsx
"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;
components/cookie-crumb.tsx
"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 unitsconst CX = 100;const NS = "http://www.w3.org/2000/svg";const HOLD = 860; // ms the cookie is held and nibbledconst BITES = [150, 430, 710];const SIZE = 1.3; // cookies at the bun's scaleconst 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;
components/ui-interaction-motion.ts
'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 };  });}

Registry item: /r/cookie-consent.json