Cookie consent
A cookie consent surface has two parts: a bar that asks once, and a panel that lets the user choose by category, by vendor and by cookie. Both are built from recipes already in the system. There is no consent theme and no third-party style sheet.
Which component is this?
It is not a Banner: a banner states a fact and is never a question. It is not a Dialog in the bar: the bar does not block the page. The panel is a Drawer (a Dialog with placement="side"), because its body scrolls and its footer stays.
The bar
We use essential cookies. With your consent, we also measure how the site is used in more detail. Cookie policy · Privacy policy
The bar has exactly two buttons: Accept and Preferences. They are the same recipe, the same size and the same weight. Reject all is in the panel footer, with the same weight as Accept all. At 640px and below the two bar buttons sit in two equal columns. The bar never takes more than a quarter of the screen height at 390px.
The panel
Choose which cookies we may use. You can change this at any time.
The controls are Checkboxes, not Switches. A Switch applies a setting at once; this panel has a Save choices button, so by the rule on the Checkbox page it is a Checkbox. A category is the parent of its vendors: it shows the dash (indeterminate) when they differ. indeterminate is a DOM property: set el.indeterminate = true.
Specification
| Part | Recipe or token | Why |
|---|---|---|
| Bar surface | --color-surface-raised, border-t --color-border, --shadow-overlay | The page stays usable; a hairline and the overlay shadow separate it |
| Bar text | text-sm, --color-text-secondary, DS link style | The reading colour at the dense size |
| Buttons | button({ variant: 'secondary', size: 'md' }), all of them | Same weight for every choice. Blue navigates, black commits: a consent button is neither a link nor the one committing action |
| Height and radius | At least 44px (min-h-control-md; the recipe renders 46px with a 15px line, padding and border), rounded-md (4px) | The touch floor and the system corner |
| Panel shell | dialog({ placement: 'side', size: 'sm' }) | A scrolling body with a fixed footer; full width on a phone |
| Category and vendor | checkbox(), hairline rows, no filled blocks | The choice lands on Save; a fill on hover looks like a selection |
| Category state | Checked, dash, or empty, and the words “On”, “Partly on”, “Off”, “Always on” | Colour and shape are never the only signal |
| Necessary | Checked and disabled, with “Always on” | Readable and focusable, not editable |
| Vendor details | Inline disclosure with aria-expanded; label and value rows in text-sm | No second dialog on top of a dialog |
| Cookie list | table() from 768px; one card per row below | Name, provider, purpose, expiry, storage, country. No sideways scroll |
| Count | badge() next to the category name | “2 cookies” is a label |
| Footer buttons | button secondary md, three of them, stacked and full width at every width | Same weight, and no label wraps: in a 448px drawer three buttons in a row make “Save choices” wrap |
| Focus | The one system ring. Focus moves into the panel, is trapped, and returns to the opener on close. Esc closes without saving | Keyboard and screen reader parity |
Rules
- No library theme. A consent engine may keep the state, the cookie and the revision. It never renders the surface: its style sheet is hard-coded and cannot be mapped to tokens.
- Nothing is pre-selected. Every optional category and vendor starts off. Nothing loads before the choice.
- A row exists only for something that is live. A category with no enabled vendor is not shown. The text of the bar follows what is live and never mentions advertising when no advertising vendor is.
- Links inside the bar and the panel use the button recipe with
variant: 'link'and are underlined at rest (add the plainunderlineutility). The variant alone underlines only on hover, and colour is not enough to mark a link in running text (WCAG 1.4.1). The same rule as links in a banner. - Withdrawing is as easy as choosing. The panel opens from a button in every footer, always one click away.
- The bar is not a marketing surface. The same anti-slop words apply as on a banner.
Structure
import { button, checkbox, dialog } from '@sportingscouter/ui';
const b = button({ variant: 'secondary', size: 'md' });
const c = checkbox(); // slots: root, input, box, mark, dash
<!-- The bar: a region, not a dialog. It does not take focus. -->
<section role="region" aria-label="Cookies" class="bg-surface-raised border-t border-border shadow-overlay">
<p class="text-sm text-text-secondary">…<a href="/cookies">Cookie policy</a></p>
<div class="grid grid-cols-2 gap-2 sm:flex">
<button class="{b}">Accept</button>
<button class="{b}">Preferences</button> <!-- opens the panel; does not navigate -->
</div>
</section>
<!-- The panel: Dialog, side, sm. Title, description, close from the slots. -->
const d = dialog({ placement: 'side', size: 'sm' });
<div role="dialog" aria-modal="true" aria-labelledby="cc-title" class="{d.content}">
<h2 id="cc-title" class="{d.title}">Cookie preferences</h2>
<fieldset>
<legend>Analytics</legend>
<!-- parent: set el.indeterminate = true in code when the vendors differ -->
<label class="{c.root}"><input type="checkbox" class="{c.input}"><span class="{c.box}"><!-- mark and dash --></span><span>Analytics</span></label>
<label class="{c.root}"><input type="checkbox" class="{c.input}"><span class="{c.box}"></span><span>Google Analytics 4</span></label>
</fieldset>
<!-- Not d.footer: it is a row from md and the third label wraps. Stack the three. -->
<div class="flex flex-col gap-2"><button class="{b} w-full">Accept all</button><button class="{b} w-full">Reject all</button><button class="{b} w-full">Save choices</button></div>
</div>Check the slot names against ui/src/primitives/selection.ts and dialog.ts before copying. The strings in an app are a copy of the recipe output and a test must fail when they differ.
theme.css.