{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cookie-consent",
  "type": "registry:ui",
  "title": "Cookie consent",
  "description": "A consent bar that morphs into preferences and folds into a reopen button.",
  "categories": [
    "feedback"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json",
    "https://mcp.xzero.co.uk/r/button.json",
    "https://mcp.xzero.co.uk/r/switch.json"
  ],
  "files": [
    {
      "path": "registry/components/cookie-consent/cookie-consent.tsx",
      "type": "registry:component",
      "target": "@components/xzero/cookie-consent/cookie-consent.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Cookie, X } from \"lucide-react\";\nimport Button from \"../button/button\";\nimport { Switch } from \"../switch/switch\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./cookie-consent.module.css\";\n\n/** One group of cookies a visitor can allow or refuse. */\nexport interface CookieCategory {\n  id: string;\n  label: string;\n  description?: string;\n  /** Needed for the site to work. Always on and cannot be switched off. */\n  required?: boolean;\n  /** Starting position of the switch before a choice is saved. Non-essential categories default to off. */\n  defaultEnabled?: boolean;\n}\n\n/** Category id to allowed. */\nexport type CookieConsentValue = Record<string, boolean>;\nexport type CookieConsentAction = \"accept-all\" | \"reject-all\" | \"save\" | \"reset\";\n\n/** What is written to storage under `storageKey`. */\nexport interface StoredCookieConsent {\n  value: CookieConsentValue;\n  version: string;\n  updatedAt: string;\n}\n\nexport interface CookieConsentHandle {\n  /** Opens the preferences panel, for example from a \"Cookie settings\" footer link. */\n  openPreferences: () => void;\n  /** Closes the preferences panel without saving. */\n  closePreferences: () => void;\n  /** Forgets the saved choice and asks again. */\n  reset: () => void;\n}\n\nexport interface CookieConsentLabels {\n  message: ReactNode;\n  customize: string;\n  acceptAll: string;\n  rejectAll: string;\n  save: string;\n  preferencesTitle: string;\n  preferencesDescription: ReactNode;\n  reopen: string;\n  alwaysOn: string;\n  close: string;\n}\n\n/**\n * A consent bar that morphs into a preferences panel and, once a choice is saved, folds into a small floating button that\n * reopens it. One surface springs between all three shapes. Non-essential categories start off, refusing is as easy as\n * accepting, and the choice is reported through `onChange` and can be remembered in `localStorage`.\n */\nexport interface CookieConsentProps {\n  categories?: CookieCategory[];\n  /** The saved choice. `null` means the visitor has not decided yet. Leave it out to let the component manage it. */\n  value?: CookieConsentValue | null;\n  defaultValue?: CookieConsentValue | null;\n  onChange?: (value: CookieConsentValue | null, detail: { action: CookieConsentAction; updatedAt: Date }) => void;\n  /** Remembers the choice in `localStorage` under this key. Read it elsewhere with `readCookieConsent`. */\n  storageKey?: string;\n  /** Bump when your categories or policy change to ask again. */\n  version?: string;\n  /** Whether the preferences panel is open. */\n  preferencesOpen?: boolean;\n  defaultPreferencesOpen?: boolean;\n  onPreferencesOpenChange?: (open: boolean) => void;\n  /** Link to the full policy, shown in the bar and panel. */\n  policy?: { label: string; href?: string; onClick?: () => void };\n  position?: \"bottom\" | \"bottom-left\" | \"bottom-right\";\n  /** `fixed` pins to the viewport; `absolute` fills the nearest positioned ancestor, useful for previews. */\n  strategy?: \"fixed\" | \"absolute\";\n  /** Keep a small button after a choice so visitors can change their mind. Defaults to true. */\n  showReopenButton?: boolean;\n  /** Corner for the reopen button. Defaults to the bar's side, or left when the bar is centered. */\n  reopenSide?: \"left\" | \"right\";\n  labels?: Partial<CookieConsentLabels>;\n  className?: string;\n}\n\nexport const defaultCookieCategories: CookieCategory[] = [\n  { id: \"necessary\", label: \"Strictly necessary\", description: \"Sign-in, security, and your cart. The site can't work without these.\", required: true },\n  { id: \"preferences\", label: \"Preferences\", description: \"Remember your language, region, and display settings.\" },\n  { id: \"analytics\", label: \"Analytics\", description: \"Count visits and see which pages help, only in aggregate.\" },\n  { id: \"marketing\", label: \"Marketing\", description: \"Measure campaigns and show relevant ads on other sites.\" },\n];\n\nconst defaultLabels: CookieConsentLabels = {\n  message: \"We use essential cookies to run this site. With your OK, we'd also use optional ones to remember settings and learn what helps.\",\n  customize: \"Customize\",\n  acceptAll: \"Accept all\",\n  rejectAll: \"Reject all\",\n  save: \"Save choices\",\n  preferencesTitle: \"Cookie preferences\",\n  preferencesDescription: \"Choose which optional cookies we can use. You can change this any time.\",\n  reopen: \"Cookie preferences\",\n  alwaysOn: \"Always on\",\n  close: \"Close preferences\",\n};\n\n/** Reads a saved choice, or `null` when there is none or it was saved for another version. Safe to call on the server. */\nexport function readCookieConsent(storageKey: string, version = \"1\"): StoredCookieConsent | null {\n  if (typeof window === \"undefined\") return null;\n  try {\n    const raw = window.localStorage.getItem(storageKey);\n    if (!raw) return null;\n    const parsed = JSON.parse(raw) as Partial<StoredCookieConsent>;\n    if (!parsed || typeof parsed.value !== \"object\" || parsed.value === null || parsed.version !== version) return null;\n    return { value: parsed.value, version: parsed.version, updatedAt: String(parsed.updatedAt ?? \"\") };\n  } catch {\n    return null;\n  }\n}\n\nfunction writeCookieConsent(storageKey: string, stored: StoredCookieConsent | null) {\n  try {\n    if (stored) window.localStorage.setItem(storageKey, JSON.stringify(stored));\n    else window.localStorage.removeItem(storageKey);\n  } catch { /* Storage can be blocked; the callback still reports the choice. */ }\n}\n\ntype Mode = \"bar\" | \"panel\" | \"chip\";\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps its velocity. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = 2 * Math.PI / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(.46, 0), SHRINK = physical(.38, 0);\nconst BAR_MAX = 660, PANEL_MAX = 420, CHIP = 44, STACK_BELOW = 560;\n\nconst subscribe = () => () => {};\nfunction useHydrated() { return useSyncExternalStore(subscribe, () => true, () => false); }\n\nconst faceVariants: Variants = {\n  hidden: { opacity: 0, y: 10, filter: `blur(${motionTokens.blur.soft}px)` },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { y: physical(.42, 0), opacity: { duration: .22, ease: enter, delay: .04 }, filter: { duration: .26, ease: enter, delay: .04 } } },\n  gone: { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.soft}px)`, transition: { duration: .12, ease: standard } },\n};\nconst fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: .16 } }, gone: { opacity: 0, transition: { duration: .1 } } };\n\n/** A face reports its natural height while it is current, then goes inert as it leaves. It is pinned to the side the surface is placed on, so a\n *  centered bar and panel cross-fade in place instead of the incoming face sliding in from the surface's left edge. */\nfunction Face({ id, width, side, reduced, focusOnMount, onSize, className, children, ...rest }: { id: Mode; width: number; side: \"left\" | \"right\" | \"center\"; reduced: boolean; focusOnMount: boolean; onSize: (id: Mode, height: number) => void; className?: string; children: ReactNode; role?: string; \"aria-modal\"?: boolean; \"aria-labelledby\"?: string; \"aria-describedby\"?: string; \"aria-label\"?: string; onKeyDown?: (event: ReactKeyboardEvent<HTMLDivElement>) => void }) {\n  const ref = useRef<HTMLDivElement>(null);\n  const present = useIsPresent();\n  useLayoutEffect(() => {\n    const node = ref.current;\n    if (!node || !present) return;\n    const report = () => onSize(id, node.offsetHeight);\n    report();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(report);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [id, onSize, present]);\n  useEffect(() => {\n    if (!focusOnMount) return;\n    ref.current?.querySelector<HTMLElement>(\"[data-autofocus]\")?.focus({ preventScroll: true });\n  }, [focusOnMount]);\n  return <motion.div {...rest} ref={ref} className={[styles.face, className].filter(Boolean).join(\" \")} style={{ width }} data-face={id} data-side={side}\n    variants={reduced ? fadeVariants : faceVariants} initial=\"hidden\" animate=\"shown\" exit=\"gone\" inert={!present || undefined}>{children}</motion.div>;\n}\n\nexport const CookieConsent = forwardRef<CookieConsentHandle, CookieConsentProps>(function CookieConsent({\n  categories = defaultCookieCategories, value, defaultValue = null, onChange, storageKey, version = \"1\",\n  preferencesOpen, defaultPreferencesOpen = false, onPreferencesOpenChange, policy, position = \"bottom\", strategy = \"fixed\",\n  showReopenButton = true, reopenSide, labels: labelOverrides, className,\n}, ref) {\n  const labels = { ...defaultLabels, ...labelOverrides };\n  const reduced = !!useReducedMotion();\n  const hydrated = useHydrated();\n  const uid = useId();\n  const titleId = `${uid}-title`, descriptionId = `${uid}-description`, messageId = `${uid}-message`;\n\n  /* The saved choice: controlled, or internal and seeded from storage once on the client. */\n  const [internal, setInternal] = useState<CookieConsentValue | null>(defaultValue);\n  const [loaded, setLoaded] = useState(!storageKey || value !== undefined);\n  useEffect(() => {\n    if (loaded || !storageKey) return;\n    const stored = readCookieConsent(storageKey, version);\n    // Storage is only readable after hydration; this runs once to seed the saved choice.\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    if (stored) setInternal(stored.value);\n    setLoaded(true);\n  }, [loaded, storageKey, version]);\n  const consent = value !== undefined ? value : internal;\n  const decided = consent != null;\n\n  const [panelInternal, setPanelInternal] = useState(defaultPreferencesOpen);\n  const panelOpen = preferencesOpen ?? panelInternal;\n  const setPanel = useCallback((next: boolean) => {\n    if (preferencesOpen === undefined) setPanelInternal(next);\n    onPreferencesOpenChange?.(next);\n  }, [onPreferencesOpenChange, preferencesOpen]);\n\n  const baseline = useCallback((from: CookieConsentValue | null) => Object.fromEntries(categories.map(category => [category.id, category.required ? true : from?.[category.id] ?? category.defaultEnabled ?? false])), [categories]);\n  const [draft, setDraft] = useState<CookieConsentValue>(() => baseline(consent));\n  const [focusTarget, setFocusTarget] = useState<Mode | null>(null);\n\n  const mode: Mode | null = !hydrated || !loaded ? null : panelOpen ? \"panel\" : !decided ? \"bar\" : showReopenButton ? \"chip\" : null;\n\n  const openPanel = useCallback((focus = true) => {\n    setDraft(baseline(consent));\n    setFocusTarget(focus ? \"panel\" : null);\n    setPanel(true);\n  }, [baseline, consent, setPanel]);\n\n  const closePanel = useCallback((focus = true) => {\n    setFocusTarget(focus ? (decided ? \"chip\" : \"bar\") : null);\n    setPanel(false);\n  }, [decided, setPanel]);\n\n  const commit = useCallback((next: CookieConsentValue | null, action: CookieConsentAction) => {\n    const updatedAt = new Date();\n    if (value === undefined) setInternal(next);\n    if (storageKey) writeCookieConsent(storageKey, next ? { value: next, version, updatedAt: updatedAt.toISOString() } : null);\n    onChange?.(next, { action, updatedAt });\n  }, [onChange, storageKey, value, version]);\n\n  const choose = (action: \"accept-all\" | \"reject-all\" | \"save\") => {\n    const next = action === \"save\" ? { ...draft } : Object.fromEntries(categories.map(category => [category.id, category.required ? true : action === \"accept-all\"]));\n    // Keep focus on the page: the choice folds the surface into its reopen button, which takes focus when it exists.\n    setFocusTarget(showReopenButton ? \"chip\" : null);\n    commit(next, action);\n    if (panelOpen) setPanel(false);\n  };\n\n  useImperativeHandle(ref, () => ({\n    openPreferences: () => openPanel(true),\n    closePreferences: () => closePanel(false),\n    reset: () => { setPanel(false); setFocusTarget(null); commit(null, \"reset\"); },\n  }), [closePanel, commit, openPanel, setPanel]);\n\n  /* Geometry. The layer is measured; each face gets an explicit width, reports its height, and the surface springs to it. */\n  const layerRef = useRef<HTMLDivElement>(null);\n  const [layerWidth, setLayerWidth] = useState(0);\n  useLayoutEffect(() => {\n    const node = layerRef.current;\n    if (!node) return;\n    const read = () => setLayerWidth(node.clientWidth);\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n\n  const align = position === \"bottom-left\" ? \"left\" : position === \"bottom-right\" ? \"right\" : \"center\";\n  const chipSide = reopenSide ?? (align === \"right\" ? \"right\" : \"left\");\n  const widths = { bar: Math.min(layerWidth, BAR_MAX), panel: Math.min(layerWidth, PANEL_MAX), chip: CHIP };\n  const place = (width: number, side: \"left\" | \"right\" | \"center\") => side === \"left\" ? 0 : side === \"right\" ? layerWidth - width : (layerWidth - width) / 2;\n  // Reopened preferences grow out of the corner button, so the panel opens on its side once a choice exists.\n  const panelSide = decided && showReopenButton ? chipSide : align;\n\n  const x = useMotionValue(0), width = useMotionValue(0), height = useMotionValue(0), radius = useMotionValue(22);\n  const heights = useRef<Record<Mode, number>>({ bar: 0, panel: 0, chip: CHIP });\n  const shape = useRef({ w: 0, h: 0, ready: false });\n  const [tick, setTick] = useState(0);\n  const onSize = useCallback((id: Mode, h: number) => {\n    if (heights.current[id] === h) return;\n    heights.current[id] = h;\n    setTick(count => count + 1);\n  }, []);\n\n  useLayoutEffect(() => {\n    if (!mode || !layerWidth) { shape.current.ready = false; return; }\n    const w = widths[mode], h = mode === \"chip\" ? CHIP : heights.current[mode];\n    if (!h) return;\n    const next = { x: place(w, mode === \"chip\" ? chipSide : mode === \"panel\" ? panelSide : align), w, h, r: mode === \"chip\" ? CHIP / 2 : mode === \"panel\" ? 26 : 22 };\n    if (!shape.current.ready || reduced) {\n      x.jump(next.x); width.jump(next.w); height.jump(next.h); radius.jump(next.r);\n      shape.current = { w: next.w, h: next.h, ready: true };\n      return;\n    }\n    const transition = next.w * next.h >= shape.current.w * shape.current.h ? GROW : SHRINK;\n    shape.current = { w: next.w, h: next.h, ready: true };\n    animate(x, next.x, transition); animate(width, next.w, transition); animate(height, next.h, transition); animate(radius, next.r, transition);\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [mode, layerWidth, tick, reduced, align, chipSide, panelSide]);\n\n  const onPanelKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key !== \"Escape\") return;\n    event.stopPropagation();\n    closePanel(true);\n  };\n\n  const policyLink = policy ? policy.href\n    ? <a className={styles.policy} href={policy.href} onClick={policy.onClick}>{policy.label}</a>\n    : <button type=\"button\" className={styles.policy} onClick={policy.onClick}>{policy.label}</button> : null;\n  const optional = categories.filter(category => !category.required);\n  const allowedCount = optional.filter(category => draft[category.id]).length;\n\n  return <div ref={layerRef} className={[styles.layer, strategy === \"absolute\" ? styles.absolute : \"\", className].filter(Boolean).join(\" \")} data-position={position}>\n    <AnimatePresence>\n      {mode ? <motion.div key=\"surface\" className={styles.surface} style={{ x, width, height, borderRadius: radius }}\n        initial={reduced ? { opacity: 0 } : { opacity: 0, y: 24, scale: .98 }}\n        animate={{ opacity: 1, y: 0, scale: 1, transition: reduced ? { duration: .16 } : { y: physical(.5, 0), scale: physical(.5, 0), opacity: { duration: .24, ease: enter } } }}\n        exit={reduced ? { opacity: 0, transition: { duration: .12 } } : { opacity: 0, y: 12, scale: .96, transition: { duration: .18, ease: standard } }}>\n        <AnimatePresence initial={false}>\n          {mode === \"bar\" ? <Face key=\"bar\" id=\"bar\" width={widths.bar} side={align} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === \"bar\"}\n            className={styles.bar} role=\"dialog\" aria-modal={false} aria-label={labels.preferencesTitle} aria-describedby={messageId}>\n            <div className={styles.barInner} data-stack={widths.bar < STACK_BELOW || undefined}>\n              <p id={messageId} className={styles.message}>{labels.message}{policyLink ? <> {policyLink}</> : null}</p>\n              <div className={styles.barActions}>\n                <Button size=\"sm\" variant=\"ghost\" data-autofocus onClick={() => openPanel(true)}>{labels.customize}</Button>\n                <Button size=\"sm\" variant=\"secondary\" onClick={() => choose(\"reject-all\")}>{labels.rejectAll}</Button>\n                <Button size=\"sm\" variant=\"secondary\" onClick={() => choose(\"accept-all\")}>{labels.acceptAll}</Button>\n              </div>\n            </div>\n          </Face> : null}\n\n          {mode === \"panel\" ? <Face key=\"panel\" id=\"panel\" width={widths.panel} side={panelSide} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === \"panel\"}\n            className={styles.panel} role=\"dialog\" aria-modal={false} aria-labelledby={titleId} aria-describedby={descriptionId} onKeyDown={onPanelKeyDown}>\n            <div className={styles.panelHead}>\n              <h2 id={titleId} className={styles.title} tabIndex={-1} data-autofocus>{labels.preferencesTitle}</h2>\n              <button type=\"button\" className={styles.iconButton} aria-label={labels.close} onClick={() => closePanel(true)}><X size={16} strokeWidth={1.75} aria-hidden=\"true\" /></button>\n            </div>\n            <p id={descriptionId} className={styles.description}>{labels.preferencesDescription}{policyLink ? <> {policyLink}</> : null}</p>\n            <ul className={styles.list}>\n              {categories.map(category => {\n                const switchId = `${uid}-${category.id}`, hintId = `${switchId}-hint`;\n                return <li key={category.id} className={styles.row}>\n                  <div className={styles.rowText}>\n                    <label htmlFor={category.required ? undefined : switchId} className={styles.rowLabel}>{category.label}</label>\n                    {category.description ? <p id={hintId} className={styles.rowHint}>{category.description}</p> : null}\n                  </div>\n                  {category.required\n                    ? <span className={styles.always}>{labels.alwaysOn}</span>\n                    : <Switch id={switchId} aria-describedby={category.description ? hintId : undefined} checked={!!draft[category.id]}\n                      onCheckedChange={checked => setDraft(current => ({ ...current, [category.id]: checked }))} />}\n                </li>;\n              })}\n            </ul>\n            <div className={styles.panelActions}>\n              <Button size=\"sm\" variant=\"secondary\" onClick={() => choose(\"reject-all\")}>{labels.rejectAll}</Button>\n              <Button size=\"sm\" variant=\"secondary\" onClick={() => choose(\"accept-all\")}>{labels.acceptAll}</Button>\n              <Button size=\"sm\" variant=\"primary\" className={styles.save} onClick={() => choose(\"save\")} aria-describedby={`${uid}-count`}>{labels.save}</Button>\n              <span id={`${uid}-count`} className={styles.srOnly}>{`${allowedCount} of ${optional.length} optional categories allowed`}</span>\n            </div>\n          </Face> : null}\n\n          {mode === \"chip\" ? <Face key=\"chip\" id=\"chip\" width={CHIP} side={chipSide} reduced={reduced} onSize={onSize} focusOnMount={focusTarget === \"chip\"} className={styles.chipFace}>\n            <button type=\"button\" className={styles.chip} aria-label={labels.reopen} aria-haspopup=\"dialog\" data-autofocus onClick={() => openPanel(true)}>\n              <Cookie size={20} strokeWidth={1.75} aria-hidden=\"true\" />\n            </button>\n          </Face> : null}\n        </AnimatePresence>\n      </motion.div> : null}\n    </AnimatePresence>\n  </div>;\n});\n\nexport default CookieConsent;\n"
    },
    {
      "path": "registry/components/cookie-consent/cookie-consent.module.css",
      "type": "registry:component",
      "target": "@components/xzero/cookie-consent/cookie-consent.module.css",
      "content": "/* The layer spans the bottom edge and lets clicks through; only the surface takes pointer input. */\n.layer { position: fixed; z-index: 60; right: 16px; bottom: 16px; left: 16px; height: 0; pointer-events: none; font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.absolute { position: absolute; z-index: 5; }\n\n/* One surface whose position, size, and corner spring between the bar, the panel, and the reopen button. */\n.surface { position: absolute; bottom: 0; left: 0; overflow: hidden; background: var(--surface-raised); box-shadow: 0 0 0 1px var(--border), var(--shadow-floating); color: var(--foreground); pointer-events: auto; transform-origin: bottom center; will-change: transform, width, height; }\n\n/* Faces sit on the bottom edge at their final width, so the surface grows upward into them. */\n.face { position: absolute; bottom: 0; left: 0; box-sizing: border-box; }\n.face[data-side=\"center\"] { left: 50%; translate: -50% 0; }\n.face[data-side=\"right\"] { right: 0; left: auto; }\n.face[inert] { pointer-events: none; }\n\n.bar { container-type: inline-size; padding: 10px 10px 10px 18px; }\n.barInner { display: flex; align-items: center; gap: var(--space-4); }\n.barInner[data-stack] { flex-direction: column; align-items: stretch; gap: var(--space-3); padding: 6px 0 0; }\n.message { flex: 1 1 auto; min-width: 0; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }\n.barActions { display: flex; flex: none; align-items: center; gap: 6px; }\n.barInner[data-stack] .barActions { display: grid; grid-template-columns: auto 1fr 1fr; }\n.barInner[data-stack] { padding-right: 8px; }\n\n.policy { padding: 0; border: 0; background: none; color: var(--foreground); cursor: pointer; font: inherit; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 3px; transition: text-decoration-color var(--duration-fast) var(--ease-standard); }\n\n.panel { container-type: inline-size; display: grid; gap: var(--space-3); padding: 18px; }\n.panelHead { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-block: -4px -6px; }\n.title { margin: 0; font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }\n.description { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); text-wrap: pretty; }\n.iconButton { display: inline-grid; width: 32px; height: 32px; flex: none; place-items: center; margin-right: -8px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n.iconButton:active { background: var(--surface-muted); color: var(--foreground); }\n\n.list { display: grid; max-height: var(--cookie-consent-list-max, min(46vh, 300px)); margin: 0 -6px; padding: 0; overflow-y: auto; list-style: none; overscroll-behavior: contain; scrollbar-width: thin; }\n.row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 10px 6px; border-top: 1px solid var(--border-subtle); }\n.row:first-child { border-top: 0; }\n.rowText { display: grid; min-width: 0; gap: 2px; }\n.rowLabel { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.rowLabel[for] { cursor: pointer; }\n.rowHint { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.always { flex: none; color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }\n\n.panelActions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding-top: var(--space-2); border-top: 1px solid var(--border-subtle); }\n.save { grid-column: 1 / -1; }\n\n.chipFace { height: 44px; }\n.chip { display: grid; width: 44px; height: 44px; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n.chip:active { background: var(--surface-muted); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (hover: hover) and (pointer: fine) {\n  .policy:hover { text-decoration-color: currentColor; }\n  .iconButton:hover { background: var(--surface-muted); color: var(--foreground); }\n  .chip:hover { background: var(--surface-muted); }\n}\n\n/* Below about 340px the three bar buttons no longer fit one row: refusing and accepting share it, and Customize takes the row under them. */\n@container (max-width: 300px) {\n  .barInner[data-stack] .barActions { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n  .barInner[data-stack] .barActions > :first-child { grid-column: 1 / -1; order: 1; }\n}\n\n@container (min-width: 340px) {\n  .panelActions { grid-template-columns: auto auto 1fr; }\n  .save { grid-column: auto; }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .policy, .iconButton, .chip { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/cookie-consent",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "feedback",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/cookie-consent",
    "markdown": "https://ui.xzero.co.uk/components/cookie-consent/markdown"
  }
}