# Pennant — Animated Section Block for React

> Announcement bar + cookie consent. Pennant is a slim top bar that rotates short messages with a vertical slide (paused on hover/focus, hairline progress meter), rolls an odometer countdown to a deadline and collapses on a height spring when dismissed, remembered via storageKey. Consent is a non-modal cookie card that rises into a bottom corner (full-width bar under 640px) with Accept / Reject / Customize; Customize grows the same card into per-category switches with Necessary locked on, reports onDecision, persists choices and reopens via ref from a footer link.

- Page: https://crucibleui.com/components/sections/pennant
- Category: [sections](https://crucibleui.com/components/sections)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/pennant
```

Registry JSON: https://crucibleui.com/r/pennant.json

Import from `@/components/crucible/pennant`.

## Props (`PennantProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messages?` | `PennantMessage[]` | — | Messages to rotate through with a vertical slide. One message renders static. Omit (and omit `text`) for a three-message sample set. |
| `text?` | `React.ReactNode` | — | Single-message shorthand: the announcement copy. Ignored when `messages` is set. |
| `link?` | `PennantLink` | — | Single-message shorthand: inline link. |
| `badge?` | `string` | — | Single-message shorthand: pill before the text. |
| `endsAt?` | `Date \| number \| string` | — | Single-message shorthand: rolling countdown target. |
| `interval?` | `number` | `6000` | Milliseconds each message holds before sliding to the next. |
| `dismissible?` | `boolean` | `true` | Show the dismiss button. |
| `storageKey?` | `string` | — | localStorage key that remembers a dismissal across visits. Bump the key when you publish a new announcement. Omit to never persist. |
| `appearance?` | `"subtle" \| "solid"` | `"subtle"` | `"subtle"` is a near-black bar with accent details; `"solid"` fills it with the accent. |
| `accentColor?` | `string` | `"#818cf8"` | Accent hex for the pill, link, countdown and focus rings. |
| `label?` | `string` | `"Announcement"` | Accessible name of the landmark region. |
| `onDismiss?` | `() => void` | — | Called after the bar is dismissed by the user or `dismiss()`. |
| `onMessageChange?` | `(index: number) => void` | — | Called when the visible message changes (index into the message list). |
| `ref?` | `React.Ref<PennantHandle>` | — | Imperative handle (`show`, `dismiss`, `replay`, `next`, `prev`). React 19 ref-as-prop. |
| `className?` | `string` | — | Extra classes for the outer region. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import {
  Consent,
  Pennant,
  type ConsentHandle,
  type ConsentPreferences,
  type PennantHandle,
} from "@/components/crucible/pennant";

const ACTION =
  "inline-flex h-8 items-center rounded-lg bg-white/[0.06] px-3 text-xs font-medium text-white/80 ring-1 ring-inset ring-white/[0.08] transition-colors hover:bg-white/[0.1] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#818cf8]";

export default function PennantDemo() {
  const bar = React.useRef<PennantHandle>(null);
  const consent = React.useRef<ConsentHandle>(null);
  const [choice, setChoice] = React.useState<ConsentPreferences | null>(null);

  const summary = choice
    ? (["analytics", "marketing", "preferences"] as const).filter((k) => choice[k]).join(", ") || "necessary only"
    : null;

  return (
    <div className="relative isolate flex h-[600px] w-full flex-col overflow-hidden rounded-xl bg-neutral-950">
      {/* Mock page frame: the bar sits at the very top, the consent card pins inside. */}
      <Pennant ref={bar} className="relative z-20" />

      <div className="relative z-10 flex items-center justify-between border-b border-white/[0.06] px-5 py-3.5">
        <div className="flex items-center gap-2">
          <span aria-hidden className="size-5 rounded-md bg-gradient-to-br from-white/80 to-white/30" />
          <span className="text-sm font-semibold tracking-tight text-white">Northwind</span>
        </div>
        <div aria-hidden className="hidden gap-5 sm:flex">
          {[48, 56, 40].map((w) => (
            <span key={w} className="h-2 rounded-full bg-white/10" style={{ width: w }} />
          ))}
        </div>
        <span aria-hidden className="h-7 w-20 rounded-lg bg-white/[0.06]" />
      </div>

      <div className="relative z-10 flex flex-1 flex-col items-center justify-center gap-5 px-6 pb-28 text-center sm:pb-6">
        <p className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">Dismiss, then reopen.</p>
        <p className="max-w-sm text-sm text-white/50">
          Close the bar, choose your cookies, then bring either one back.
        </p>
        <div className="flex flex-wrap justify-center gap-2">
          <button type="button" className={ACTION} onClick={() => bar.current?.show()}>
            Show banner again
          </button>
          <button type="button" className={ACTION} onClick={() => consent.current?.open()}>
            Reopen consent
          </button>
        </div>
        <p className="h-4 text-xs tabular-nums text-white/35" aria-hidden>
          {summary ? `Saved: ${summary}` : ""}
        </p>
      </div>

      <div className="relative z-10 flex items-center justify-between border-t border-white/[0.06] px-5 py-3 text-xs text-white/35">
        <span>© 2026 Northwind</span>
        <button
          type="button"
          onClick={() => consent.current?.open()}
          className="rounded underline-offset-4 hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#818cf8]"
        >
          Cookie settings
        </button>
      </div>

      <Consent
        ref={consent}
        strategy="absolute"
        policyHref="#"
        onDecision={(p) => {
          setChoice(p);
          return new Promise<void>((r) => window.setTimeout(r, 500));
        }}
      />
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`pennant.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

/* ================================================================ shared */

const DEFAULT_ACCENT = "#818cf8";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
const INSTANT: Transition = { duration: 0 };
const HEIGHT_SPRING: Transition = { type: "spring", stiffness: 420, damping: 42, mass: 0.9 };
const RISE_SPRING: Transition = { type: "spring", stiffness: 340, damping: 32, mass: 0.9 };

/** Black or white ink, whichever reads against the accent fill. */
function readableOn(hex: string): string {
  const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
  if (!m) return "#0a0a0a";
  let s = m[1];
  if (s.length === 3) s = s.replace(/./g, (c) => c + c);
  const n = parseInt(s, 16);
  const lum = 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
  return lum > 140 ? "#0a0a0a" : "#ffffff";
}

function storageGet(key: string | undefined): string | null {
  if (!key) return null;
  try {
    return window.localStorage.getItem(key);
  } catch {
    return null;
  }
}

function storageSet(key: string | undefined, value: string | null) {
  if (!key) return;
  try {
    if (value === null) window.localStorage.removeItem(key);
    else window.localStorage.setItem(key, value);
  } catch {
    /* storage blocked (private mode, sandbox) — state stays in memory */
  }
}

const FOCUS_RING =
  "outline-none focus-visible:ring-2 focus-visible:ring-[var(--pn-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950";

/* ================================================================ Pennant */

export interface PennantLink {
  /** Visible link text. */
  label: string;
  /** Destination href. */
  href: string;
}

export interface PennantMessage {
  /** Short pill before the text (e.g. "New"). Omit for no pill. */
  badge?: string;
  /** The announcement copy. Keep it to one short sentence. */
  text: React.ReactNode;
  /** Inline call-to-action link after the text. */
  link?: PennantLink;
  /** Show a rolling countdown to this moment (Date, epoch ms or ISO string). */
  endsAt?: Date | number | string;
  /** Countdown target relative to mount, in milliseconds. Handy for demos; `endsAt` wins. */
  endsIn?: number;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface PennantHandle {
  /** Bring the bar back (clears any persisted dismissal) and replay its intro. */
  show: () => void;
  /** Collapse the bar as if the dismiss button was pressed. */
  dismiss: () => void;
  /** Re-run the drop-in intro. */
  replay: () => void;
  /** Advance to the next rotating message. */
  next: () => void;
  /** Go back to the previous rotating message. */
  prev: () => void;
}

export interface PennantProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /**
   * Messages to rotate through with a vertical slide. One message renders
   * static. Omit (and omit `text`) for a three-message sample set.
   */
  messages?: PennantMessage[];
  /** Single-message shorthand: the announcement copy. Ignored when `messages` is set. */
  text?: React.ReactNode;
  /** Single-message shorthand: inline link. */
  link?: PennantLink;
  /** Single-message shorthand: pill before the text. */
  badge?: string;
  /** Single-message shorthand: rolling countdown target. */
  endsAt?: Date | number | string;
  /** Milliseconds each message holds before sliding to the next. @default 6000 */
  interval?: number;
  /** Show the dismiss button. @default true */
  dismissible?: boolean;
  /**
   * localStorage key that remembers a dismissal across visits. Bump the key
   * when you publish a new announcement. Omit to never persist.
   */
  storageKey?: string;
  /** `"subtle"` is a near-black bar with accent details; `"solid"` fills it with the accent. @default "subtle" */
  appearance?: "subtle" | "solid";
  /** Accent hex for the pill, link, countdown and focus rings. @default "#818cf8" */
  accentColor?: string;
  /** Accessible name of the landmark region. @default "Announcement" */
  label?: string;
  /** Called after the bar is dismissed by the user or `dismiss()`. */
  onDismiss?: () => void;
  /** Called when the visible message changes (index into the message list). */
  onMessageChange?: (index: number) => void;
  /** Imperative handle (`show`, `dismiss`, `replay`, `next`, `prev`). React 19 ref-as-prop. */
  ref?: React.Ref<PennantHandle>;
  /** Extra classes for the outer region. */
  className?: string;
}

const DEFAULT_MESSAGES: PennantMessage[] = [
  {
    badge: "New",
    text: "Workflows 2.0 is here: branch, retry and schedule any step.",
    link: { label: "See what's new", href: "#" },
  },
  {
    badge: "Offer",
    text: "Early-bird pricing ends in",
    endsIn: (2 * 24 * 60 + 14 * 60 + 7) * 60_000 + 31_000,
    link: { label: "Claim 30% off", href: "#" },
  },
  {
    badge: "Live",
    text: "Join Thursday's product walkthrough with the team.",
    link: { label: "Save a seat", href: "#" },
  },
];

function toTime(v: Date | number | string | undefined): number | null {
  if (v === undefined) return null;
  const t = v instanceof Date ? v.getTime() : typeof v === "number" ? v : Date.parse(v);
  return Number.isFinite(t) ? t : null;
}

/** One odometer digit: the new value rolls down in from above. */
function RollDigit({ digit, reduced }: { digit: string; reduced: boolean }) {
  return (
    <span className="relative inline-flex h-[1.3em] w-[1ch] justify-center overflow-hidden">
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={digit}
          className="block leading-[1.3em]"
          initial={reduced ? { opacity: 0 } : { y: "-100%", opacity: 0 }}
          animate={{ y: "0%", opacity: 1 }}
          exit={reduced ? { opacity: 0 } : { y: "100%", opacity: 0 }}
          transition={reduced ? { duration: 0.15 } : { type: "spring", stiffness: 520, damping: 40 }}
        >
          {digit}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

function Countdown({ target, solid, reduced }: { target: number; solid: boolean; reduced: boolean }) {
  const [now, setNow] = React.useState<number | null>(null);

  React.useEffect(() => {
    let timer = 0;
    const tick = () => {
      const t = Date.now();
      setNow(t);
      if (t < target) timer = window.setTimeout(tick, 1000 - (t % 1000) + 8);
    };
    tick();
    return () => window.clearTimeout(timer);
  }, [target]);

  const left = Math.max(0, now === null ? 0 : target - now);
  const s = Math.floor(left / 1000);
  const parts: [string, string, string][] = [
    ["d", "days", String(Math.min(99, Math.floor(s / 86400))).padStart(2, "0")],
    ["h", "hours", String(Math.floor((s % 86400) / 3600)).padStart(2, "0")],
    ["m", "minutes", String(Math.floor((s % 3600) / 60)).padStart(2, "0")],
    ["s", "seconds", String(s % 60).padStart(2, "0")],
  ];
  const spoken =
    now === null ? "" : parts.slice(0, 3).map(([, unit, v]) => `${Number(v)} ${unit}`).join(", ") + " left";

  return (
    <span
      role="timer"
      aria-label={spoken}
      className={cn(
        "inline-flex shrink-0 items-center gap-1 font-mono text-[12px] font-medium tabular-nums",
        now === null && "opacity-0"
      )}
    >
      {parts.map(([u, , v]) => (
        <span
          key={u}
          aria-hidden
          className={cn(
            "inline-flex items-baseline rounded-[5px] px-1 ring-1 ring-inset",
            solid ? "bg-black/15 ring-black/10" : "bg-white/[0.06] text-white ring-white/[0.08]"
          )}
        >
          {v.split("").map((d, i) => (
            <RollDigit key={i} digit={d} reduced={reduced} />
          ))}
          <span className={cn("ml-px text-[10px]", solid ? "opacity-60" : "text-white/40")}>{u}</span>
        </span>
      ))}
    </span>
  );
}

/**
 * Pennant — a slim top-of-page announcement bar. Rotates short messages with a
 * vertical slide (paused while hovered or focused, with a hairline progress
 * meter), rolls an odometer countdown when a message has a deadline, and
 * collapses on a height spring when dismissed — remembering it via
 * `storageKey`. Under reduced motion messages crossfade and only change on
 * demand.
 */
export function Pennant({
  messages,
  text,
  link,
  badge,
  endsAt,
  interval = 6000,
  dismissible = true,
  storageKey,
  appearance = "subtle",
  accentColor = DEFAULT_ACCENT,
  label = "Announcement",
  onDismiss,
  onMessageChange,
  ref,
  className,
  style,
  ...rest
}: PennantProps) {
  const reduced = useReducedMotion();
  const solid = appearance === "solid";
  const ink = readableOn(accentColor);

  const list = React.useMemo<PennantMessage[]>(() => {
    if (messages && messages.length) return messages;
    if (text !== undefined) return [{ text, link, badge, endsAt }];
    return DEFAULT_MESSAGES;
  }, [messages, text, link, badge, endsAt]);

  // Hidden until mounted when persisting, so a stored dismissal never flashes.
  const [visible, setVisible] = React.useState(!storageKey);
  const [introKey, setIntroKey] = React.useState(0);
  const [index, setIndex] = React.useState(0);
  const [dir, setDir] = React.useState<1 | -1>(1);
  const [hovered, setHovered] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const [onscreen, setOnscreen] = React.useState(true);
  const [mountedAt, setMountedAt] = React.useState<number | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const raf = requestAnimationFrame(() => {
      setMountedAt(Date.now());
      if (storageKey) setVisible(storageGet(storageKey) !== "dismissed");
    });
    return () => cancelAnimationFrame(raf);
  }, [storageKey]);

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([e]) => setOnscreen(e.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const count = list.length;
  const current = Math.min(index, count - 1);
  const rotating = count > 1 && !reduced;
  const paused = hovered || focused || !onscreen;

  const go = React.useCallback(
    (step: 1 | -1) => {
      if (count < 2) return;
      setDir(step);
      setIndex((i) => {
        const n = (Math.min(i, count - 1) + step + count) % count;
        onMessageChange?.(n);
        return n;
      });
    },
    [count, onMessageChange]
  );

  const dismiss = React.useCallback(() => {
    setVisible(false);
    storageSet(storageKey, "dismissed");
    onDismiss?.();
  }, [storageKey, onDismiss]);

  React.useImperativeHandle(
    ref,
    () => ({
      show: () => {
        storageSet(storageKey, null);
        setVisible(true);
        setIntroKey((k) => k + 1);
      },
      dismiss,
      replay: () => setIntroKey((k) => k + 1),
      next: () => go(1),
      prev: () => go(-1),
    }),
    [storageKey, dismiss, go]
  );

  const msg = list[current];
  const target =
    toTime(msg.endsAt) ?? (msg.endsIn !== undefined && mountedAt !== null ? mountedAt + msg.endsIn : null);

  const slide = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { y: dir > 0 ? "70%" : "-70%", opacity: 0, filter: "blur(2px)" },
        animate: { y: "0%", opacity: 1, filter: "blur(0px)" },
        exit: { y: dir > 0 ? "-70%" : "70%", opacity: 0, filter: "blur(2px)" },
      };

  const ctrlBtn = cn(
    "grid size-7 shrink-0 place-items-center rounded-md transition-colors",
    FOCUS_RING,
    solid ? "opacity-70 hover:bg-black/10 hover:opacity-100" : "text-white/45 hover:bg-white/[0.06] hover:text-white"
  );

  return (
    <AnimatePresence initial={!reduced}>
      {visible && (
        <motion.div
          key="pennant"
          {...(rest as Omit<typeof rest, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart">)}
          ref={rootRef}
          role="region"
          aria-label={label}
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={{ height: 0, opacity: 0 }}
          transition={reduced ? { duration: 0.2 } : HEIGHT_SPRING}
          onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
          onPointerLeave={() => setHovered(false)}
          onFocus={() => setFocused(true)}
          onBlur={(e) => {
            if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
          }}
          className={cn("@container relative w-full overflow-hidden", className)}
          style={
            {
              "--pn-accent": accentColor,
              background: solid ? accentColor : undefined,
              color: solid ? ink : undefined,
              ...style,
            } as React.CSSProperties
          }
        >
          {!solid && (
            <>
              <div aria-hidden className="absolute inset-0 bg-neutral-950" />
              <div
                aria-hidden
                className="absolute inset-0"
                style={{
                  background: `radial-gradient(60% 140% at 50% 0%, color-mix(in oklab, ${accentColor} 16%, transparent), transparent 70%)`,
                }}
              />
              <div
                aria-hidden
                className="absolute inset-x-0 bottom-0 h-px"
                style={{
                  background: `linear-gradient(90deg, rgba(255,255,255,0.06), color-mix(in oklab, ${accentColor} 55%, transparent) 50%, rgba(255,255,255,0.06))`,
                }}
              />
            </>
          )}

          <motion.div
            key={introKey}
            initial={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
            animate={{ opacity: 1, y: 0 }}
            transition={reduced ? { duration: 0.2 } : { duration: 0.5, ease: EASE, delay: 0.12 }}
            className="relative flex min-h-11 items-center gap-2 px-2 @lg:px-3"
          >
            {/* Left spacer mirrors the controls so the message stays optically centred. */}
            <div aria-hidden className={cn("shrink-0", count > 1 ? "w-7 @2xl:w-[4.25rem]" : "w-7", !dismissible && count < 2 && "w-0")} />

            <div
              className="relative grid min-w-0 flex-1 place-items-center overflow-hidden py-2"
              aria-roledescription={count > 1 ? "carousel" : undefined}
            >
              <AnimatePresence initial={false} mode="popLayout" custom={dir}>
                <motion.div
                  key={current}
                  aria-roledescription={count > 1 ? "slide" : undefined}
                  aria-label={count > 1 ? `${current + 1} of ${count}` : undefined}
                  initial={slide.initial}
                  animate={slide.animate}
                  exit={slide.exit}
                  transition={reduced ? { duration: 0.2 } : { type: "spring", stiffness: 380, damping: 36 }}
                  className="flex min-w-0 flex-wrap items-center justify-center gap-x-2.5 gap-y-1 text-center text-[12.5px] leading-snug @lg:text-[13px]"
                >
                  {msg.badge && (
                    <span
                      className="hidden shrink-0 rounded-full px-2 py-px text-[10.5px] font-semibold uppercase tracking-[0.08em] @md:inline-block"
                      style={
                        solid
                          ? { background: "rgba(0,0,0,0.14)" }
                          : {
                              color: accentColor,
                              background: `color-mix(in oklab, ${accentColor} 14%, transparent)`,
                              boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${accentColor} 30%, transparent)`,
                            }
                      }
                    >
                      {msg.badge}
                    </span>
                  )}
                  <span className={cn("font-medium", solid ? "" : "text-white/80")}>{msg.text}</span>
                  {target !== null && <Countdown target={target} solid={solid} reduced={reduced} />}
                  {msg.link && (
                    <a
                      href={msg.link.href}
                      className={cn(
                        "group/link inline-flex shrink-0 items-center gap-1 rounded font-semibold underline-offset-4 hover:underline",
                        FOCUS_RING
                      )}
                      style={solid ? undefined : { color: accentColor }}
                    >
                      {msg.link.label}
                      <svg
                        aria-hidden
                        viewBox="0 0 16 16"
                        className="size-3.5 transition-transform duration-200 group-hover/link:translate-x-0.5"
                        fill="none"
                      >
                        <path d="M3 8h9m0 0L8.5 4.5M12 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
                      </svg>
                    </a>
                  )}
                </motion.div>
              </AnimatePresence>
            </div>

            <div className="flex shrink-0 items-center gap-0.5">
              {count > 1 && (
                <div className="hidden items-center gap-0.5 @2xl:flex">
                  <button type="button" aria-label="Previous announcement" onClick={() => go(-1)} className={ctrlBtn}>
                    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
                      <path d="M10 3.5 5.5 8l4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                  </button>
                  <button type="button" aria-label="Next announcement" onClick={() => go(1)} className={ctrlBtn}>
                    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
                      <path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                  </button>
                </div>
              )}
              {count > 1 && (
                <button type="button" aria-label="Next announcement" onClick={() => go(1)} className={cn(ctrlBtn, "@2xl:hidden", dismissible && "hidden")}>
                  <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
                    <path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
                  </svg>
                </button>
              )}
              {dismissible && (
                <button type="button" aria-label="Dismiss announcement" onClick={dismiss} className={ctrlBtn}>
                  <svg aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="none">
                    <path d="m4.5 4.5 7 7m0-7-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
                  </svg>
                </button>
              )}
            </div>
          </motion.div>

          {/* Rotation meter: a hairline that fills over `interval`; its end advances the message. */}
          {rotating && (
            <div aria-hidden className="absolute inset-x-0 bottom-0 h-px">
              <div
                key={`${current}-${introKey}`}
                className="h-full origin-left"
                onAnimationEnd={() => go(1)}
                style={{
                  background: solid ? "currentColor" : accentColor,
                  opacity: solid ? 0.35 : 0.9,
                  animation: `pennant-meter ${interval}ms linear forwards`,
                  animationPlayState: paused ? "paused" : "running",
                }}
              />
              <style>{`@keyframes pennant-meter{from{transform:scaleX(0)}to{transform:scaleX(1)}}`}</style>
            </div>
          )}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/* ================================================================ Consent */

/** The four consent categories. `necessary` is always `true`. */
export interface ConsentPreferences {
  /** Strictly necessary cookies — always on. */
  necessary: true;
  /** Usage measurement. */
  analytics: boolean;
  /** Ads and attribution. */
  marketing: boolean;
  /** Remembered settings such as language or theme. */
  preferences: boolean;
}

export type ConsentCategory = Exclude<keyof ConsentPreferences, "necessary">;

export interface ConsentCategoryCopy {
  /** Switch label. */
  label: string;
  /** One-line description under the label. */
  description: string;
}

/** Imperative verbs exposed through the `ref` prop. */
export interface ConsentHandle {
  /** Open the card (expanded to the saved choices if a decision exists) and move focus into it. */
  open: () => void;
  /** Close the card without recording a decision. */
  close: () => void;
  /** Forget the stored decision, restore default switches and reopen collapsed. */
  reset: () => void;
}

export interface ConsentProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children" | "title"> {
  /** Controlled visibility. Pair with `onOpenChange`. */
  open?: boolean;
  /**
   * Uncontrolled initial visibility. Omit to open automatically only when no
   * decision is stored under `storageKey`.
   */
  defaultOpen?: boolean;
  /** Called whenever the card asks to open or close. */
  onOpenChange?: (open: boolean) => void;
  /**
   * Called with the final choices. Return a Promise to hold the pressed
   * button in a pending state; a rejection shows its message inline and keeps
   * the card open.
   */
  onDecision?: (preferences: ConsentPreferences) => void | Promise<unknown>;
  /** localStorage key that remembers the decision (JSON). Omit to never persist. */
  storageKey?: string;
  /** Heading. @default "We value your privacy" */
  title?: React.ReactNode;
  /** Short body copy under the heading. */
  description?: React.ReactNode;
  /** Optional "Cookie policy" link appended to the copy. */
  policyHref?: string;
  /** Override labels and descriptions per category. */
  categories?: Partial<Record<keyof ConsentPreferences, Partial<ConsentCategoryCopy>>>;
  /** Initial switch values for the optional categories. @default all off */
  defaultPreferences?: Partial<Record<ConsentCategory, boolean>>;
  /** Which bottom corner the card rises into (≥640px). @default "left" */
  side?: "left" | "right";
  /**
   * `"fixed"` pins to the viewport; `"absolute"` pins to the nearest
   * positioned ancestor (previews, mock frames). @default "fixed"
   */
  strategy?: "fixed" | "absolute";
  /** Seconds before an auto-open card rises in. @default 0.6 */
  delay?: number;
  /** Accent hex for the primary button, switches and focus rings. @default "#818cf8" */
  accentColor?: string;
  /** Imperative handle (`open`, `close`, `reset`). React 19 ref-as-prop. */
  ref?: React.Ref<ConsentHandle>;
  /** Extra classes for the card. */
  className?: string;
}

const CATEGORY_ORDER: (keyof ConsentPreferences)[] = ["necessary", "analytics", "marketing", "preferences"];

const CATEGORY_COPY: Record<keyof ConsentPreferences, ConsentCategoryCopy> = {
  necessary: { label: "Necessary", description: "Sign-in, security and load balancing. The site can't work without these." },
  analytics: { label: "Analytics", description: "Anonymous usage counts that show us which pages help and which don't." },
  marketing: { label: "Marketing", description: "Measures campaigns and limits how often you see the same ad elsewhere." },
  preferences: { label: "Preferences", description: "Remembers choices like language, region and theme between visits." },
};

type Pending = "accept" | "reject" | "save" | null;

function readStored(key: string | undefined): ConsentPreferences | null {
  const raw = storageGet(key);
  if (!raw) return null;
  try {
    const p = JSON.parse(raw) as { prefs?: Partial<ConsentPreferences> };
    if (!p.prefs) return null;
    return {
      necessary: true,
      analytics: !!p.prefs.analytics,
      marketing: !!p.prefs.marketing,
      preferences: !!p.prefs.preferences,
    };
  } catch {
    return null;
  }
}

function Switch({
  checked,
  locked,
  onChange,
  labelId,
  descId,
  reduced,
}: {
  checked: boolean;
  locked?: boolean;
  onChange: (v: boolean) => void;
  labelId: string;
  descId: string;
  reduced: boolean;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      aria-disabled={locked || undefined}
      aria-labelledby={labelId}
      aria-describedby={descId}
      onClick={() => !locked && onChange(!checked)}
      className={cn(
        "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full p-0.5 transition-colors duration-200",
        FOCUS_RING,
        locked ? "cursor-not-allowed opacity-60" : "cursor-pointer",
        checked ? "" : "bg-white/[0.12] hover:bg-white/[0.16]"
      )}
      style={checked ? { background: "var(--pn-accent)" } : undefined}
    >
      <motion.span
        aria-hidden
        className="block size-4 rounded-full shadow-[0_1px_2px_rgba(0,0,0,0.4)]"
        style={{ background: checked ? "var(--pn-ink)" : "#e5e5e5" }}
        animate={{ x: checked ? 16 : 0 }}
        transition={reduced ? INSTANT : { type: "spring", stiffness: 700, damping: 38 }}
      />
    </button>
  );
}

function Spinner() {
  return (
    <svg aria-hidden viewBox="0 0 16 16" className="size-3.5 animate-spin motion-reduce:animate-none" fill="none">
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
      <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

/**
 * Consent — a cookie-consent card that rises into a bottom corner (a
 * full-width bar under 640px). "Customize" grows the same card into
 * per-category switches instead of opening a second modal. Non-modal dialog:
 * the page stays usable, Escape folds the details away, and opening it via
 * the ref (e.g. a footer "Cookie settings" link) moves focus into it and
 * returns focus when it closes.
 */
export function Consent({
  open: openProp,
  defaultOpen,
  onOpenChange,
  onDecision,
  storageKey,
  title = "We value your privacy",
  description = "We use cookies to keep the site secure, understand what's working and personalise what you see. Choose what you're comfortable with.",
  policyHref,
  categories,
  defaultPreferences,
  side = "left",
  strategy = "fixed",
  delay = 0.6,
  accentColor = DEFAULT_ACCENT,
  ref,
  className,
  style,
  ...rest
}: ConsentProps) {
  const reduced = useReducedMotion();
  const ink = readableOn(accentColor);
  const uid = React.useId();
  const controlled = openProp !== undefined;

  const initialPrefs = React.useCallback(
    (): Omit<ConsentPreferences, "necessary"> => ({
      analytics: !!defaultPreferences?.analytics,
      marketing: !!defaultPreferences?.marketing,
      preferences: !!defaultPreferences?.preferences,
    }),
    [defaultPreferences]
  );

  const [innerOpen, setInnerOpen] = React.useState(false);
  const [expanded, setExpanded] = React.useState(false);
  const [prefs, setPrefs] = React.useState(initialPrefs);
  const [pending, setPending] = React.useState<Pending>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [announce, setAnnounce] = React.useState("");
  const [autoDelay, setAutoDelay] = React.useState(true);

  const isOpen = controlled ? !!openProp : innerOpen;
  const cardRef = React.useRef<HTMLDivElement>(null);
  const returnFocus = React.useRef<HTMLElement | null>(null);
  const wantFocus = React.useRef(false);
  const focusSwitch = React.useRef(false);
  const decided = React.useRef(false);

  // "Customize" unmounts itself — hand focus to the first optional switch.
  React.useEffect(() => {
    if (!expanded || !focusSwitch.current) return;
    focusSwitch.current = false;
    cardRef.current?.querySelector<HTMLElement>('[role="switch"]:not([aria-disabled])')?.focus({ preventScroll: true });
  }, [expanded]);

  // Uncontrolled: decide on mount whether to show (never during SSR).
  React.useEffect(() => {
    const raf = requestAnimationFrame(() => {
      const stored = readStored(storageKey);
      if (stored) setPrefs({ analytics: stored.analytics, marketing: stored.marketing, preferences: stored.preferences });
      if (!controlled) setInnerOpen(defaultOpen ?? !stored);
    });
    return () => cancelAnimationFrame(raf);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [storageKey]);

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (!controlled) setInnerOpen(next);
      onOpenChange?.(next);
    },
    [controlled, onOpenChange]
  );

  // Restore focus to wherever it came from when the card closes with focus inside.
  const closeCard = React.useCallback(() => {
    const card = cardRef.current;
    if (card && card.contains(document.activeElement) && returnFocus.current?.isConnected) {
      returnFocus.current.focus();
    }
    returnFocus.current = null;
    setOpen(false);
  }, [setOpen]);

  const openCard = React.useCallback(
    (opts: { expand: boolean }) => {
      const active = document.activeElement;
      if (active instanceof HTMLElement && !cardRef.current?.contains(active)) returnFocus.current = active;
      wantFocus.current = true;
      setAutoDelay(false);
      setError(null);
      setPending(null);
      setExpanded(opts.expand);
      setOpen(true);
      if (cardRef.current) {
        requestAnimationFrame(() => cardRef.current?.focus({ preventScroll: true }));
        wantFocus.current = false;
      }
    },
    [setOpen]
  );

  React.useImperativeHandle(
    ref,
    () => ({
      open: () => {
        const stored = readStored(storageKey);
        if (stored) setPrefs({ analytics: stored.analytics, marketing: stored.marketing, preferences: stored.preferences });
        openCard({ expand: !!stored || decided.current });
      },
      close: closeCard,
      reset: () => {
        decided.current = false;
        storageSet(storageKey, null);
        setPrefs(initialPrefs());
        openCard({ expand: false });
      },
    }),
    [storageKey, openCard, closeCard, initialPrefs]
  );

  const decide = async (kind: Exclude<Pending, null>) => {
    if (pending) return;
    const chosen: ConsentPreferences =
      kind === "accept"
        ? { necessary: true, analytics: true, marketing: true, preferences: true }
        : kind === "reject"
          ? { necessary: true, analytics: false, marketing: false, preferences: false }
          : { necessary: true, ...prefs };
    setError(null);
    try {
      const result = onDecision?.(chosen);
      if (result && typeof (result as Promise<unknown>).then === "function") {
        setPending(kind);
        await result;
      }
    } catch (e) {
      setPending(null);
      setError(e instanceof Error && e.message ? e.message : "Couldn't save your choices. Please try again.");
      return;
    }
    setPending(null);
    decided.current = true;
    setPrefs({ analytics: chosen.analytics, marketing: chosen.marketing, preferences: chosen.preferences });
    storageSet(storageKey, JSON.stringify({ v: 1, prefs: chosen, at: new Date().toISOString() }));
    setAnnounce(
      kind === "accept" ? "All cookies accepted." : kind === "reject" ? "Optional cookies rejected." : "Cookie choices saved."
    );
    closeCard();
  };

  const copy = (k: keyof ConsentPreferences): ConsentCategoryCopy => ({ ...CATEGORY_COPY[k], ...categories?.[k] });

  const enterDelay = autoDelay && !reduced ? delay : 0;
  const btnBase =
    "relative inline-flex h-9 items-center justify-center gap-1.5 rounded-lg px-3.5 text-[13px] font-medium transition-[background-color,color,box-shadow,opacity] duration-150 disabled:cursor-default";
  const btnGhost = cn(btnBase, FOCUS_RING, "text-white/70 hover:bg-white/[0.06] hover:text-white");
  const btnSecondary = cn(
    btnBase,
    FOCUS_RING,
    "bg-white/[0.06] text-white ring-1 ring-inset ring-white/[0.08] hover:bg-white/[0.1]"
  );
  const btnPrimary = cn(btnBase, FOCUS_RING, "shadow-[0_1px_0_rgba(255,255,255,0.25)_inset] hover:opacity-90");
  const busy = pending !== null;
  const label = (kind: Exclude<Pending, null>, text: string) => (
    <>
      {pending === kind && <Spinner />}
      {text}
    </>
  );

  return (
    <>
      <span role="status" className="sr-only">
        {announce}
      </span>
      <AnimatePresence>
        {isOpen && (
          <motion.div
            key="consent"
            {...(rest as Omit<typeof rest, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart">)}
            ref={(el: HTMLDivElement | null) => {
              cardRef.current = el;
              if (el && wantFocus.current) {
                wantFocus.current = false;
                requestAnimationFrame(() => el.focus({ preventScroll: true }));
              }
            }}
            role="dialog"
            aria-modal="false"
            aria-labelledby={`${uid}-title`}
            aria-describedby={`${uid}-desc`}
            tabIndex={-1}
            onKeyDown={(e) => {
              if (e.key === "Escape" && expanded && !busy) {
                e.stopPropagation();
                setExpanded(false);
                cardRef.current?.focus({ preventScroll: true });
              }
            }}
            initial={reduced ? { opacity: 0 } : { opacity: 0, y: 28, scale: 0.98 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={reduced ? { opacity: 0 } : { opacity: 0, y: 18, scale: 0.98 }}
            transition={
              reduced
                ? { duration: 0.2 }
                : { ...RISE_SPRING, delay: enterDelay, opacity: { duration: 0.35, ease: EASE, delay: enterDelay } }
            }
            className={cn(
              strategy === "fixed" ? "fixed" : "absolute",
              "z-50 flex flex-col overflow-hidden bg-neutral-900/95 text-white shadow-[0_24px_60px_-12px_rgba(0,0,0,0.7)] outline-none backdrop-blur-xl",
              "inset-x-0 bottom-0 max-h-[85%] rounded-t-2xl border-t border-white/[0.08]",
              "sm:inset-x-auto sm:bottom-4 sm:w-[min(400px,calc(100%-2rem))] sm:rounded-2xl sm:border",
              side === "left" ? "sm:left-4" : "sm:right-4",
              strategy === "fixed" && "max-h-[85vh]",
              className
            )}
            style={{ "--pn-accent": accentColor, "--pn-ink": ink, ...style } as React.CSSProperties}
          >
            {/* Accent hairline along the top edge. */}
            <div
              aria-hidden
              className="pointer-events-none absolute inset-x-6 top-0 h-px"
              style={{ background: `linear-gradient(90deg, transparent, color-mix(in oklab, ${accentColor} 70%, transparent), transparent)` }}
            />

            <div className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain p-5", expanded ? "pb-4" : "pb-0")}>
              <div className="flex items-start gap-3">
                <span
                  aria-hidden
                  className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg"
                  style={{
                    color: accentColor,
                    background: `color-mix(in oklab, ${accentColor} 13%, transparent)`,
                    boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${accentColor} 26%, transparent)`,
                  }}
                >
                  <svg viewBox="0 0 20 20" className="size-4" fill="none">
                    <path
                      d="M10 2.75a7.25 7.25 0 1 0 7.2 6.4 2.4 2.4 0 0 1-3.1-2.2 2.4 2.4 0 0 1-2.85-2.85 2.4 2.4 0 0 1-1.25-1.35Z"
                      stroke="currentColor"
                      strokeWidth="1.5"
                      strokeLinejoin="round"
                    />
                    <circle cx="7.25" cy="9" r="1" fill="currentColor" />
                    <circle cx="11" cy="12.75" r="1" fill="currentColor" />
                    <circle cx="7.5" cy="13" r="0.75" fill="currentColor" />
                  </svg>
                </span>
                <div className="min-w-0">
                  <h2 id={`${uid}-title`} className="text-[15px] font-semibold tracking-tight text-white">
                    {title}
                  </h2>
                  <p id={`${uid}-desc`} className="mt-1 text-[13px] leading-relaxed text-white/55">
                    {description}
                    {policyHref && (
                      <>
                        {" "}
                        <a
                          href={policyHref}
                          className={cn("rounded font-medium text-white/80 underline decoration-white/25 underline-offset-4 hover:text-white", FOCUS_RING)}
                        >
                          Cookie policy
                        </a>
                      </>
                    )}
                  </p>
                </div>
              </div>

              <AnimatePresence initial={false}>
                {expanded && (
                  <motion.div
                    key="details"
                    initial={{ height: 0, opacity: 0 }}
                    animate={{ height: "auto", opacity: 1 }}
                    exit={{ height: 0, opacity: 0 }}
                    transition={reduced ? { duration: 0.15 } : { height: HEIGHT_SPRING, opacity: { duration: 0.25, ease: EASE } }}
                    className="overflow-hidden"
                  >
                    <ul
                      aria-label="Cookie categories"
                      className="mt-4 divide-y divide-white/[0.06] rounded-xl border border-white/[0.07] bg-white/[0.02]"
                    >
                      {CATEGORY_ORDER.map((k, i) => {
                        const c = copy(k);
                        const locked = k === "necessary";
                        const checked = locked ? true : prefs[k as ConsentCategory];
                        return (
                          <motion.li
                            key={k}
                            initial={reduced ? false : { opacity: 0, y: 6 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={reduced ? INSTANT : { duration: 0.35, ease: EASE, delay: 0.05 + i * 0.045 }}
                            className="flex items-start gap-3 px-3.5 py-3"
                          >
                            <div className="min-w-0 flex-1">
                              <div className="flex items-center gap-2">
                                <span id={`${uid}-${k}-l`} className="text-[13px] font-medium text-white">
                                  {c.label}
                                </span>
                                {locked && (
                                  <span className="rounded-full bg-white/[0.06] px-1.5 py-px text-[10px] font-medium uppercase tracking-[0.06em] text-white/45">
                                    Always on
                                  </span>
                                )}
                              </div>
                              <p id={`${uid}-${k}-d`} className="mt-0.5 text-[12px] leading-relaxed text-white/45">
                                {c.description}
                              </p>
                            </div>
                            <div className="pt-0.5">
                              <Switch
                                checked={checked}
                                locked={locked}
                                labelId={`${uid}-${k}-l`}
                                descId={`${uid}-${k}-d`}
                                reduced={reduced}
                                onChange={(v) => setPrefs((p) => ({ ...p, [k]: v }))}
                              />
                            </div>
                          </motion.li>
                        );
                      })}
                    </ul>
                  </motion.div>
                )}
              </AnimatePresence>

              <AnimatePresence initial={false}>
                {error && (
                  <motion.p
                    key="err"
                    role="alert"
                    initial={{ opacity: 0, height: 0 }}
                    animate={{ opacity: 1, height: "auto" }}
                    exit={{ opacity: 0, height: 0 }}
                    transition={reduced ? INSTANT : { duration: 0.25, ease: EASE }}
                    className="overflow-hidden text-[12px] text-rose-300"
                  >
                    <span className="block pt-3">{error}</span>
                  </motion.p>
                )}
              </AnimatePresence>
            </div>

            <div className={cn("flex flex-wrap items-center gap-2 p-5 pt-4", expanded && "border-t border-white/[0.06] pt-4")}>
              {expanded ? (
                <>
                  <button type="button" className={cn(btnGhost, "-ml-2")} disabled={busy} onClick={() => decide("reject")}>
                    {label("reject", "Reject all")}
                  </button>
                  <div className="ml-auto flex gap-2">
                    <button type="button" className={btnSecondary} disabled={busy} onClick={() => decide("accept")}>
                      {label("accept", "Accept all")}
                    </button>
                    <button
                      type="button"
                      className={btnPrimary}
                      style={{ background: accentColor, color: ink }}
                      disabled={busy}
                      onClick={() => decide("save")}
                    >
                      {label("save", "Save choices")}
                    </button>
                  </div>
                </>
              ) : (
                <>
                  <button
                    type="button"
                    className={cn(btnGhost, "-ml-2")}
                    aria-expanded={false}
                    disabled={busy}
                    onClick={() => {
                      focusSwitch.current = true;
                      setExpanded(true);
                    }}
                  >
                    Customize
                  </button>
                  <div className="ml-auto flex gap-2">
                    <button type="button" className={btnSecondary} disabled={busy} onClick={() => decide("reject")}>
                      {label("reject", "Reject all")}
                    </button>
                    <button
                      type="button"
                      className={btnPrimary}
                      style={{ background: accentColor, color: ink }}
                      disabled={busy}
                      onClick={() => decide("accept")}
                    >
                      {label("accept", "Accept all")}
                    </button>
                  </div>
                </>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </>
  );
}
```
