{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cookie-banner","type":"registry:component","title":"Cookie banner","description":"Asks once, plainly, then folds into a small cookie button in the corner that grows back into the preferences.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/cookie-banner.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * COOKIE BANNER: ask once, plainly, then get out of the way\n *\n *   ask        a hairline card in the corner: one sentence,\n *              Customise, and Reject all beside Accept all,\n *              equally easy\n *   customise  the same card grows into the preferences: each\n *              kind of cookie with a switch thrown to its side;\n *              what's necessary says it's always on, and why\n *   save       a check draws itself on the button you pressed,\n *              its label morphs to \"Accepted\", and the card\n *              shrinks into a small cookie button in the corner\n *   revisit    that button grows back into the preferences, with\n *              the choices as they were\n *\n * The choice is remembered and handed to you, so you only load\n * what people allowed. Global Privacy Control is respected; new\n * kinds of cookie, or a year passing, ask again.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CookieCategory {\n  id: string;\n  label: string;\n  description: string;\n  /** Always on, e.g. what keeps people signed in. */\n  required?: boolean;\n  /** Ads or cross-site tracking: stays off for browsers that send Global Privacy Control, unless switched on here. */\n  tracking?: boolean;\n}\n\nexport type CookieConsent = Record<string, boolean>;\n\nexport interface CookieBannerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  categories?: CookieCategory[];\n  /** The one sentence on the banner. */\n  message?: React.ReactNode;\n  /** Link to your cookie policy. */\n  policyHref?: string;\n  /**\n   * Called with the remembered choice on load (\"load\") and with every new one (\"save\"),\n   * so you can start what people allowed. Return a promise to record consent on your\n   * server; the button waits for it.\n   */\n  onConsentChange?: (consent: CookieConsent, reason: \"load\" | \"save\") => void | Promise<unknown>;\n  storageKey?: string;\n  /** Days before asking again. */\n  expiresAfter?: number;\n  position?: \"bottom-left\" | \"bottom-right\" | \"bottom-center\";\n  /** \"absolute\" keeps it inside a positioned parent instead of the window. */\n  strategy?: \"fixed\" | \"absolute\";\n  /** Keep the small cookie button after a choice, so people can change their mind. */\n  showReopen?: boolean;\n}\n\ntype Phase = \"hidden\" | \"ask\" | \"prefs\" | \"folded\";\ntype Action = \"accept\" | \"reject\" | \"save\";\ntype Work = { action: Action; stage: \"busy\" | \"done\" };\ntype Saved = { choices: CookieConsent; savedAt: number };\n\nconst OPEN_EVENT = \"cookie-preferences:open\";\n\n/** Opens the preferences from anywhere, e.g. a \"Cookie settings\" link in your footer. */\nexport function openCookiePreferences() {\n  window.dispatchEvent(new Event(OPEN_EVENT));\n}\n\n/** The saved choice, or null if people haven't chosen yet. */\nexport function getCookieConsent(storageKey = \"cookie-consent\"): CookieConsent | null {\n  try {\n    return (JSON.parse(localStorage.getItem(storageKey) ?? \"null\") as Saved | null)?.choices ?? null;\n  } catch {\n    return null;\n  }\n}\n\nconst DEFAULT_CATEGORIES: CookieCategory[] = [\n  { id: \"necessary\", label: \"Necessary\", description: \"Keeps you signed in and your basket full.\", required: true },\n  { id: \"analytics\", label: \"Analytics\", description: \"Counts visits and what gets used, so we can make it better. Never sold.\" },\n  { id: \"marketing\", label: \"Marketing\", description: \"Shows you our ads on other sites and tells us if they worked.\", tracking: true },\n];\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst DURATION = 480;\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-popover\";\nconst TONES = {\n  solid: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n  line: \"text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent\",\n  ghost: \"text-muted-foreground hover:bg-accent hover:text-foreground\",\n};\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction CookieIcon({ className = \"size-4\" }: { className?: string }) {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 20 20\" fill=\"none\" className={className}>\n      <path\n        d=\"M17.5 10.2A7.5 7.5 0 1 1 9.8 2.5a2.6 2.6 0 0 0 3.1 3 2.6 2.6 0 0 0 3.5 3.5 2.4 2.4 0 0 0 1.1 1.2Z\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinejoin=\"round\"\n      />\n      <circle cx=\"7.3\" cy=\"8.6\" r=\"1\" fill=\"currentColor\" />\n      <circle cx=\"12.2\" cy=\"13\" r=\"1\" fill=\"currentColor\" />\n      <circle cx=\"7.8\" cy=\"13.4\" r=\"0.9\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\n/** A button that confirms itself: a spinner while your server records it, then a drawn check and a new label. */\nfunction ActionButton({\n  label,\n  doneLabel,\n  stage,\n  locked,\n  tone,\n  reduced,\n  onClick,\n  className = \"\",\n}: {\n  label: string;\n  doneLabel: string;\n  stage: \"idle\" | Work[\"stage\"];\n  locked: boolean;\n  tone: keyof typeof TONES;\n  reduced: boolean;\n  onClick: () => void;\n  className?: string;\n}) {\n  const on = stage !== \"idle\";\n  return (\n    <button\n      type=\"button\"\n      onClick={() => !locked && onClick()}\n      aria-disabled={(locked && !on) || undefined}\n      aria-busy={stage === \"busy\" || undefined}\n      className={`inline-flex h-8 shrink-0 items-center justify-center rounded-full px-3.5 text-[12.5px] font-medium transition-colors ${FOCUS} ${TONES[tone]} ${className}`}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"relative flex h-3.5 shrink-0 items-center justify-center\"\n        style={{ width: on ? 14 : 0, marginRight: on ? 6 : 0, transition: reduced ? \"none\" : `width 320ms ${EASE}, margin 320ms ${EASE}` }}\n      >\n        <span\n          className={`absolute size-3 rounded-full border-[1.5px] border-current/30 border-t-current motion-reduce:animate-none ${stage === \"busy\" ? \"animate-spin\" : \"\"}`}\n          style={{ opacity: stage === \"busy\" ? 1 : 0, transition: reduced ? \"none\" : \"opacity 200ms\" }}\n        />\n        <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"absolute size-3.5\" style={{ opacity: stage === \"done\" ? 1 : 0 }}>\n          <path\n            d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            pathLength={1}\n            strokeDasharray={1}\n            style={{ strokeDashoffset: stage === \"done\" ? 0 : 1, transition: stage === \"done\" && !reduced ? `stroke-dashoffset 380ms ${EASE} 120ms` : \"none\" }}\n          />\n        </svg>\n      </span>\n      <TextMorph>{stage === \"done\" ? doneLabel : label}</TextMorph>\n    </button>\n  );\n}\n\nfunction Switch({ id, checked, describedBy, reduced, onChange }: { id: string; checked: boolean; describedBy: string; reduced: boolean; onChange: (on: boolean) => void }) {\n  return (\n    <button\n      id={id}\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-describedby={describedBy}\n      onClick={() => onChange(!checked)}\n      className={`relative mt-0.5 h-6 w-10 shrink-0 rounded-full ${FOCUS} ${checked ? \"bg-primary\" : \"bg-muted shadow-[inset_0_0_0_1px_var(--border)]\"}`}\n      style={{ transition: reduced ? \"none\" : `background-color 300ms ${EASE}` }}\n    >\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute left-0.5 top-0.5 size-5 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n        style={{ transform: checked ? \"translateX(16px)\" : \"none\", transition: reduced ? \"none\" : `transform 400ms ${THROW}` }}\n      />\n    </button>\n  );\n}\n\nexport function CookieBanner({\n  categories = DEFAULT_CATEGORIES,\n  message = \"We use cookies to keep the site working and, if you agree, to see how it’s used.\",\n  policyHref,\n  onConsentChange,\n  storageKey = \"cookie-consent\",\n  expiresAfter = 365,\n  position = \"bottom-left\",\n  strategy = \"fixed\",\n  showReopen = true,\n  className = \"\",\n  ...props\n}: CookieBannerProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const required = React.useCallback(() => Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required)])), [categories]);\n  const [phase, setPhase] = React.useState<Phase>(\"hidden\");\n  const [work, setWork] = React.useState<Work | null>(null);\n  const [choice, setChoice] = React.useState<CookieConsent>(required);\n  const [gpc, setGpc] = React.useState(false);\n  const [size, setSize] = React.useState({ ask: { w: 0, h: 0 }, prefs: { w: 0, h: 0 } });\n  const [room, setRoom] = React.useState(432);\n  const regionRef = React.useRef<HTMLDivElement>(null);\n  const cardRef = React.useRef<HTMLDivElement>(null);\n  const askRef = React.useRef<HTMLDivElement>(null);\n  const prefsRef = React.useRef<HTMLDivElement>(null);\n  const customiseRef = React.useRef<HTMLButtonElement>(null);\n  const cornerRef = React.useRef<HTMLButtonElement>(null);\n  const live = React.useRef({ phase, locked: false });\n  const back = React.useRef<Phase>(\"ask\");\n  const saved = React.useRef<CookieConsent | null>(null);\n  const focusTo = React.useRef<\"prefs\" | \"ask\" | \"corner\" | null>(null);\n  const timers = React.useRef<number[]>([]);\n  const change = React.useRef(onConsentChange);\n\n  React.useEffect(() => {\n    live.current.phase = phase;\n    change.current = onConsentChange;\n  });\n\n  const later = (fn: () => void, ms: number) => {\n    timers.current.push(window.setTimeout(fn, ms));\n  };\n  React.useEffect(() => () => timers.current.forEach(window.clearTimeout), []);\n\n  // On load: a remembered, current choice goes straight to the corner button; otherwise ask.\n  React.useEffect(() => {\n    setGpc(Boolean((navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl));\n    try {\n      const stored = JSON.parse(localStorage.getItem(storageKey) ?? \"null\") as Saved | null;\n      if (stored?.choices) {\n        setChoice({ ...required(), ...stored.choices });\n        const fresh = Date.now() - stored.savedAt < expiresAfter * 864e5;\n        const complete = categories.every((c) => c.required || c.id in stored.choices);\n        if (fresh && complete) {\n          saved.current = stored.choices;\n          change.current?.(stored.choices, \"load\");\n          setPhase(\"folded\");\n          return;\n        }\n      }\n    } catch {}\n    setPhase(\"ask\");\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [storageKey]);\n\n  const visible = phase !== \"hidden\" && (phase !== \"folded\" || showReopen);\n\n  // How much room there is: the window, or the parent when it's positioned inside one.\n  React.useLayoutEffect(() => {\n    if (!visible) return;\n    const parent = strategy === \"absolute\" ? regionRef.current?.offsetParent : null;\n    const fit = () => setRoom(parent instanceof HTMLElement ? parent.clientWidth : document.documentElement.clientWidth);\n    fit();\n    window.addEventListener(\"resize\", fit);\n    const observer = parent instanceof HTMLElement ? new ResizeObserver(fit) : null;\n    if (parent instanceof HTMLElement) observer?.observe(parent);\n    return () => {\n      window.removeEventListener(\"resize\", fit);\n      observer?.disconnect();\n    };\n  }, [visible, strategy]);\n\n  // The card's two open shapes, measured, so it can grow from one to the other.\n  React.useLayoutEffect(() => {\n    if (!visible) return;\n    const measure = () => {\n      const a = askRef.current;\n      const p = prefsRef.current;\n      setSize((s) => {\n        const next = { ask: { w: a?.offsetWidth ?? 0, h: a?.offsetHeight ?? 0 }, prefs: { w: p?.offsetWidth ?? 0, h: p?.offsetHeight ?? 0 } };\n        return s.ask.w === next.ask.w && s.ask.h === next.ask.h && s.prefs.w === next.prefs.w && s.prefs.h === next.prefs.h ? s : next;\n      });\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (askRef.current) observer.observe(askRef.current);\n    if (prefsRef.current) observer.observe(prefsRef.current);\n    return () => observer.disconnect();\n  }, [visible]);\n\n  // Arrives by rising out of the corner, a beat after the page.\n  React.useLayoutEffect(() => {\n    if (!visible || reduced) return;\n    cardRef.current?.animate([{ transform: \"translateY(20px) scale(0.97)\", opacity: 0 }, { transform: \"none\", opacity: 1 }], {\n      duration: 640,\n      delay: phase === \"ask\" ? 400 : 0,\n      easing: EASE,\n      fill: \"backwards\",\n    });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [visible]);\n\n  // Focus follows the surface, so keyboard people never land on something that just faded.\n  React.useEffect(() => {\n    const target = focusTo.current;\n    if (!target) return;\n    focusTo.current = null;\n    if (target === \"prefs\") prefsRef.current?.querySelector<HTMLElement>('[role=\"switch\"]')?.focus({ preventScroll: true });\n    if (target === \"ask\") customiseRef.current?.focus({ preventScroll: true });\n    if (target === \"corner\") cornerRef.current?.focus({ preventScroll: true });\n  }, [phase]);\n\n  const openPrefs = () => {\n    const p = live.current.phase;\n    if (live.current.locked || p === \"hidden\" || p === \"prefs\") return;\n    back.current = p;\n    focusTo.current = \"prefs\";\n    setPhase(\"prefs\");\n  };\n\n  React.useEffect(() => {\n    window.addEventListener(OPEN_EVENT, openPrefs);\n    return () => window.removeEventListener(OPEN_EVENT, openPrefs);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const close = () => {\n    if (live.current.locked) return;\n    const to = back.current === \"folded\" ? \"folded\" : \"ask\";\n    if (to === \"folded\" && saved.current) setChoice({ ...required(), ...saved.current });\n    focusTo.current = to === \"folded\" ? \"corner\" : \"ask\";\n    setPhase(to);\n  };\n\n  const commit = async (action: Action, consent: CookieConsent) => {\n    if (live.current.locked) return;\n    live.current.locked = true;\n    const final = Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required || consent[c.id])]));\n    setChoice(final);\n    saved.current = final;\n    try {\n      localStorage.setItem(storageKey, JSON.stringify({ choices: final, savedAt: Date.now() } satisfies Saved));\n    } catch {}\n    const pending = change.current?.(final, \"save\");\n    if (pending && typeof pending.then === \"function\") {\n      setWork({ action, stage: \"busy\" });\n      await pending.then(undefined, () => {});\n    }\n    setWork({ action, stage: \"done\" });\n    later(() => {\n      if (cardRef.current?.contains(document.activeElement)) focusTo.current = \"corner\";\n      setPhase(\"folded\");\n      later(() => {\n        setWork(null);\n        live.current.locked = false;\n      }, 500);\n    }, 950);\n  };\n\n  const all = (on: boolean) => Object.fromEntries(categories.map((c) => [c.id, Boolean(c.required || (on && !(gpc && c.tracking)))]));\n  const stageOf = (action: Action) => (work?.action === action ? work.stage : \"idle\");\n  const locked = work !== null;\n  const prefs = phase === \"prefs\";\n  const folded = phase === \"folded\";\n  const shape = folded ? { w: 44, h: 44 } : prefs ? size.prefs : size.ask;\n  const place = position === \"bottom-right\" ? \"right-4\" : position === \"bottom-center\" ? \"left-1/2 -translate-x-1/2\" : \"left-4\";\n  const fadeIn = (delay: number) => (reduced ? \"none\" : `opacity 320ms ${EASE} ${delay}ms, filter 320ms ${EASE} ${delay}ms`);\n  const fadeOut = reduced ? \"none\" : \"opacity 120ms ease-out, filter 120ms ease-out\";\n\n  if (!visible) return null;\n\n  return (\n    <div\n      ref={regionRef}\n      role=\"region\"\n      aria-label=\"Cookie consent\"\n      className={`${strategy} bottom-4 z-[80] ${place} ${className}`}\n      onKeyDown={(e) => {\n        if (e.key === \"Escape\" && prefs) close();\n      }}\n      {...props}\n    >\n      {/* One surface: the banner, the preferences and the corner button are three sizes of it. */}\n      <div\n        ref={cardRef}\n        className=\"relative overflow-hidden bg-popover text-popover-foreground\"\n        style={{\n          width: shape.w || undefined,\n          height: shape.h || undefined,\n          borderRadius: folded ? 22 : 20,\n          boxShadow: \"inset 0 0 0 1px var(--border)\",\n          transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n        }}\n      >\n        {/* The ask. */}\n        <div\n          ref={askRef}\n          inert={phase !== \"ask\"}\n          aria-hidden={phase !== \"ask\" || undefined}\n          className=\"absolute bottom-0 left-0 p-4\"\n          style={{ width: Math.min(400, room - 32), opacity: phase === \"ask\" ? 1 : 0, filter: phase === \"ask\" ? \"none\" : \"blur(4px)\", transition: phase === \"ask\" ? fadeIn(160) : fadeOut }}\n        >\n          <div className=\"flex gap-3\">\n            <span className=\"mt-[3px] text-muted-foreground\">\n              <CookieIcon />\n            </span>\n            <p className=\"text-[13px] leading-relaxed text-foreground\">\n              {message}\n              {policyHref && (\n                <>\n                  {\" \"}\n                  <a href={policyHref} className={`rounded-sm text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground hover:decoration-current ${FOCUS}`}>\n                    Cookie policy\n                  </a>\n                </>\n              )}\n            </p>\n          </div>\n          {/* Reject all and Accept all share the width equally; in a narrow card they take their own row. */}\n          <div className=\"mt-4 flex flex-wrap-reverse items-center gap-2\">\n            <button\n              ref={customiseRef}\n              type=\"button\"\n              onClick={openPrefs}\n              aria-disabled={locked || undefined}\n              className={`-ml-1.5 h-8 shrink-0 rounded-full px-2.5 text-[12.5px] font-medium ${FOCUS} ${TONES.ghost}`}\n            >\n              Customise\n            </button>\n            <div className=\"ml-auto flex min-w-[200px] max-w-[248px] flex-1 gap-2\">\n              <ActionButton label=\"Reject all\" doneLabel=\"Rejected\" stage={stageOf(\"reject\")} locked={locked} tone=\"line\" reduced={reduced} onClick={() => commit(\"reject\", all(false))} className=\"flex-1\" />\n              <ActionButton label=\"Accept all\" doneLabel=\"Accepted\" stage={stageOf(\"accept\")} locked={locked} tone=\"solid\" reduced={reduced} onClick={() => commit(\"accept\", all(true))} className=\"flex-1\" />\n            </div>\n          </div>\n        </div>\n\n        {/* The preferences. */}\n        <div\n          ref={prefsRef}\n          role=\"group\"\n          aria-labelledby={`${id}-title`}\n          inert={!prefs}\n          aria-hidden={!prefs || undefined}\n          className=\"absolute bottom-0 left-0 p-4\"\n          style={{ width: Math.min(420, room - 32), opacity: prefs ? 1 : 0, filter: prefs ? \"none\" : \"blur(4px)\", transition: prefs ? fadeIn(180) : fadeOut }}\n        >\n          <div className=\"flex items-start justify-between gap-3\">\n            <div>\n              <p id={`${id}-title`} className=\"text-[14px] font-medium text-foreground\">\n                Choose your cookies\n              </p>\n              {gpc && <p className=\"mt-1 text-[12px] leading-relaxed text-muted-foreground\">Your browser asks sites not to track you, so tracking stays off unless you switch it on.</p>}\n            </div>\n            <button type=\"button\" aria-label=\"Close\" onClick={close} className={`-mr-1.5 -mt-1 grid size-7 shrink-0 place-items-center rounded-full ${FOCUS} ${TONES.ghost}`}>\n              <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"size-3.5\">\n                <path d=\"m4 4 8 8M12 4l-8 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n              </svg>\n            </button>\n          </div>\n          <ul className=\"mt-3 max-h-[min(55vh,360px)] divide-y divide-border overflow-y-auto overscroll-contain border-y border-border\">\n            {categories.map((c) => (\n              <li key={c.id} className=\"flex items-start gap-4 py-3 pr-1\">\n                <div className=\"min-w-0 flex-1\">\n                  {c.required ? (\n                    <p className=\"text-[13px] font-medium text-foreground\">{c.label}</p>\n                  ) : (\n                    <label htmlFor={`${id}-${c.id}`} className=\"cursor-pointer text-[13px] font-medium text-foreground\">\n                      {c.label}\n                    </label>\n                  )}\n                  <p id={`${id}-${c.id}-about`} className=\"mt-0.5 text-[12px] leading-relaxed text-muted-foreground\">\n                    {c.description}\n                  </p>\n                </div>\n                {c.required ? (\n                  <span className=\"mt-0.5 shrink-0 text-[12px] font-medium text-muted-foreground\">Always on</span>\n                ) : (\n                  <Switch\n                    id={`${id}-${c.id}`}\n                    checked={Boolean(choice[c.id])}\n                    describedBy={`${id}-${c.id}-about`}\n                    reduced={reduced}\n                    onChange={(on) => !locked && setChoice((x) => ({ ...x, [c.id]: on }))}\n                  />\n                )}\n              </li>\n            ))}\n          </ul>\n          <div className=\"mt-3 flex items-center justify-end gap-2\">\n            <ActionButton label=\"Reject all\" doneLabel=\"Rejected\" stage={stageOf(\"reject\")} locked={locked} tone=\"line\" reduced={reduced} onClick={() => commit(\"reject\", all(false))} />\n            <ActionButton label=\"Save choices\" doneLabel=\"Saved\" stage={stageOf(\"save\")} locked={locked} tone=\"solid\" reduced={reduced} onClick={() => commit(\"save\", choice)} />\n          </div>\n        </div>\n\n        {/* The corner button it shrinks into; it grows back into the preferences. */}\n        <button\n          ref={cornerRef}\n          type=\"button\"\n          inert={!folded}\n          aria-hidden={!folded || undefined}\n          aria-label=\"Cookie preferences\"\n          onClick={openPrefs}\n          className=\"group absolute inset-0 flex items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50\"\n          style={{\n            opacity: folded ? 1 : 0,\n            transform: folded ? \"none\" : \"scale(0.5) rotate(-40deg)\",\n            transition: reduced ? \"none\" : folded ? `opacity 260ms ${EASE} 280ms, transform 520ms ${THROW} 240ms` : \"opacity 100ms ease-out, transform 200ms ease-out\",\n          }}\n        >\n          <span className=\"transition-transform duration-300 group-hover:-rotate-[18deg] motion-reduce:transition-none\">\n            <CookieIcon className=\"size-5\" />\n          </span>\n        </button>\n      </div>\n      <span role=\"status\" className=\"sr-only\">\n        {work?.stage === \"done\" ? \"Cookie choices saved\" : \"\"}\n      </span>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/cookie-banner"}