# Cookie consent

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

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

## Install

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

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

- npm: none
- Hairline UI components it needs (the CLI installs them too): `status-button`, `text-morph`, `switch`
- Docs and live preview: https://hairlineui.com/ui/cookie-consent
- Registry item (source): https://hairlineui.com/r/cookie-consent.json

## Usage

```tsx
import { CookieConsent } from "@/components/cookie-consent";

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

## Show it once, then load scripts by consent

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

```tsx
"use client";
import { useEffect, useState } from "react";
import { CookieConsent, type CookieChoices } from "@/components/cookie-consent";

const read = (): CookieChoices | null => {
  const m = document.cookie.match(/(?:^|; )consent=([^;]*)/);
  return m ? JSON.parse(decodeURIComponent(m[1])) : null;
};

function load(choices: CookieChoices) {
  if (choices.analytics) import("@/lib/analytics").then((a) => a.start());
  if (choices.marketing) import("@/lib/pixels").then((p) => p.start());
}

export function Consent() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const saved = read();
    if (saved) load(saved);
    else setOpen(true);
  }, []);

  if (!open) return null;
  return (
    <div className="fixed bottom-4 left-4 z-50 w-[min(380px,calc(100vw-2rem))]">
      <CookieConsent
        policyHref="/cookies"
        onSave={async (choices) => {
          document.cookie = `consent=${encodeURIComponent(JSON.stringify(choices))}; max-age=31536000; path=/; samesite=lax`;
          load(choices);
          setTimeout(() => setOpen(false), 1400); // after Crumb's wave
        }}
      />
    </div>
  );
}
```

## Props

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

## States

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

## What it handles

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

- **A reaction to every choice**: Each category switched on sends Crumb a cookie; switched off, it hands the cookie back with a bite out of it.
- **A mood you can read**: Crumb gets rounder and happier with each cookie: hungry, content, then stuffed and sparkling.
- **Reject as easy as Accept**: Reject all sits beside the main button at the same size, and nothing optional is on until someone says so.
- **Necessary, plainly**: Required categories show Always on and can’t be switched off.
- **The right button**: The main button reads Accept all, or Save choices once some but not all are on; both go Saving → Saved.
- **Kept your way**: onSave gets { [category]: on } for a cookie, your consent platform or your API; a failure shakes and says why.

## Replaces

a cookie banner, a preferences modal, category switches, accept and reject buttons, a consent save call

## Where it fits

- **Cookie banners**: The first-visit consent card, in a corner or centred, with a mascot people play with.
- **Privacy settings**: The cookie preferences page people come back to, with the same choices.
- **Playful brands**: Consumer apps, games and shops whose voice is warmer than a legal notice.
- **Launch pages**: A consent card that gets shared instead of skipped.

## Notes

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

License: MIT (https://hairlineui.com/license#components)
