Components

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

The page behind keeps working. Nothing is dimmed.

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

Cookie preferences

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

PartRecipe or tokenWhy
Bar surface--color-surface-raised, border-t --color-border, --shadow-overlayThe page stays usable; a hairline and the overlay shadow separate it
Bar texttext-sm, --color-text-secondary, DS link styleThe reading colour at the dense size
Buttonsbutton({ variant: 'secondary', size: 'md' }), all of themSame weight for every choice. Blue navigates, black commits: a consent button is neither a link nor the one committing action
Height and radiusAt 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 shelldialog({ placement: 'side', size: 'sm' })A scrolling body with a fixed footer; full width on a phone
Category and vendorcheckbox(), hairline rows, no filled blocksThe choice lands on Save; a fill on hover looks like a selection
Category stateChecked, dash, or empty, and the words “On”, “Partly on”, “Off”, “Always on”Colour and shape are never the only signal
NecessaryChecked and disabled, with “Always on”Readable and focusable, not editable
Vendor detailsInline disclosure with aria-expanded; label and value rows in text-smNo second dialog on top of a dialog
Cookie listtable() from 768px; one card per row belowName, provider, purpose, expiry, storage, country. No sideways scroll
Countbadge() next to the category name“2 cookies” is a label
Footer buttonsbutton secondary md, three of them, stacked and full width at every widthSame weight, and no label wraps: in a 448px drawer three buttons in a row make “Save choices” wrap
FocusThe one system ring. Focus moves into the panel, is trapped, and returns to the opener on close. Esc closes without savingKeyboard 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 plain underline utility). 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.

Sporting Scouter Design System
Every token on this site is generated from theme.css.