Components
Consent
Two layers. A banner that asks once, and a settings dialog that lets someone answer per category and change their mind later. The site is German and sets more than strictly necessary cookies, so this is not a nicety — TDDDG § 25 requires the question to be asked before anything non-essential is stored.
Layer 1 — the banner
Pinned to the bottom of the viewport, on black, with the foil as a 2 px edge along the top. Shown here in place rather than fixed; press Replay the real banner below to see it behave.
<!-- immediately after the skip link, before the nav and everything else --> <section class="cf-consent" data-cf-consent-banner hidden role="dialog" aria-labelledby="cf-consent-title"> <div class="container cf-consent__inner"> <div> <h2 class="cf-consent__title" id="cf-consent-title" tabindex="-1" data-cf-consent-title> Datenschutz-Einstellungen </h2> <p class="cf-consent__body"> Notwendige Cookies brauchen wir, damit die Seite funktioniert. … <a href="/datenschutz">Datenschutzerklärung</a> </p> </div> <div class="cf-consent__actions"> <button type="button" class="cf-btn cf-btn--outline" data-cf-consent-action="reject">Nur notwendige</button> <button type="button" class="cf-btn cf-btn--outline" data-cf-consent-action="accept">Alle akzeptieren</button> <button type="button" class="cf-btn cf-btn--ghost" data-cf-consent-action="open">Einstellungen</button> </div> </div> </section>
Layer 2 — the settings dialog
A real <dialog> opened with showModal(): the focus trap,
the inert background, Esc and the ::backdrop all come from the
browser rather than from us. It is the same dialog the footer link reopens, which is how
withdrawal works — Art. 7(3) DSGVO wants taking consent back to be as easy as giving it,
so it is literally the same control.
<dialog class="cf-consent__dialog" data-cf-consent-dialog aria-labelledby="cf-consent-settings-title"> <div class="cf-consent__dialog-inner"> <h2 class="cf-consent__dialog-title" id="cf-consent-settings-title">Ihre Einstellungen</h2> <!-- the route to the inventory. Informed consent needs the list reachable --> <p class="cf-consent__body"> Sie entscheiden pro Kategorie. … <a href="/datenschutz#cookies">Alle Einträge im Detail</a> </p> <div class="cf-consent__group"> <div class="cf-consent__row"> <label class="cf-check"> <input class="cf-check__box" type="checkbox" checked disabled> <span class="cf-check__label"><strong>Notwendig</strong>Sitzung, Sprachwahl …</span> </label> <p class="cf-consent__meta">Immer aktiv · 1 Eintrag · 12 Monate</p> </div> <div class="cf-consent__row"> <label class="cf-check"> <input class="cf-check__box" type="checkbox" data-cf-consent-category="statistik"> <span class="cf-check__label"><strong>Statistik</strong>Anonyme Reichweitenmessung …</span> </label> <p class="cf-consent__meta">Optional · 2 Einträge · max. 13 Monate</p> </div> </div> <div class="cf-consent__actions"> <button type="button" class="cf-btn cf-btn--outline" data-cf-consent-action="reject">Nur notwendige</button> <button type="button" class="cf-btn cf-btn--outline" data-cf-consent-action="accept">Alle akzeptieren</button> <button type="button" class="cf-btn cf-btn--ghost" data-cf-consent-action="save">Auswahl speichern</button> </div> </div> </dialog>
The inventory
The dialog says 3 Einträge. Somewhere the site has to say which three,
and that somewhere is the Datenschutzerklärung the banner links
to. Consent is only informed if what is being stored can be read before it is agreed to:
per entry, the name it is stored under, what kind of storage it is, what it is for, who
the recipient is, and how long it lives.
TDDDG § 25 covers any storing of or access to information
in the visitor's terminal equipment — localStorage and
sessionStorage as much as cookies — so Art is a column rather than
an assumption baked into the heading.
Three tables rather than one with a category column: the three match the three rows of
the dialog one for one, and the count in each caption is the number the dialog quotes.
The drawing is Components · Table — on the policy page it is
reached as .cf-prose table, which is the same rules under a second selector
rather than a second table.
/datenschutz#cookies the dialog above routes to. It used to be rendered
here as well, on the grounds that copy which will be replaced wholesale should exist
once and the real page did not exist yet. It does now, so the second copy is the only
thing left that could drift, and this is the one that goes: a component page documents
the shape of the table, and the site states what is actually stored.
What the audit replaces is the rows. The five columns stay, because they are
what TDDDG § 25 and Art. 13 DSGVO ask for,
and so does the arithmetic: a seventh entry moves the count in the table's caption
and the count in .cf-consent__meta in the dialog markup on every
page that carries it.
The checkbox
.cf-check is a real <input type="checkbox"> with
appearance: none. The element keeps its role, its state and its keyboard
behaviour; only the paint is ours, so there is no ARIA to get wrong. Square, 1 px
contour, no radius. Checked fills with --gradient-foil rather than lime,
because a checkbox is never the one thing happening on a screen — and the tick is two
borders rotated 45°, which keeps it on the sanctioned angles.
Try it
This page carries the real component. Replaying clears the stored decision and reloads, so the banner comes back exactly as a first-time visitor meets it.
Gating a script
Anything non-essential ships as type="text/plain" with a category on it.
The browser will not fetch or run it. On grant, cf-consent.js copies the
attributes onto a real <script> and swaps it in.
<script type="text/plain" data-cf-consent="statistik" src="/js/analytics.js"></script> // or react to the decision directly <script> document.addEventListener('cf:consent', function (event) { if (event.detail.granted.includes('marketing')) { … } }); </script>
Revoking cannot unload a script that already ran, so a decision that removes a category reloads the page. That is the honest behaviour, and it is why the reload is not configurable.
Anatomy
| Part | Value |
|---|---|
| Surface | --surface-inverse — opaque, never frosted glass |
| Top edge | --stroke-2, painted with --gradient-foil via border-image |
| Lime | none — the page under the banner has already spent it |
| Title | Geist Mono, 11 px, uppercase, --tracking-wide |
| Body | Geist, 14 px, measure capped at 68 characters |
| Actions | two .cf-btn--outline, one .cf-btn--ghost |
| Actions, under 900 | the pair shares one row wherever the row holds both at natural width (flex-basis: max-content, so the fold tracks the rendered labels, not a typed number — from ~500 of viewport in fallback metrics); below that each takes the full measure. The ghost has its own line too, unless the screen is also under 35rem tall, where it rejoins the row — that row is 52 px of the 68 the short-screen form has to save, and the pair still fills the measure so nothing is squeezed to make room for it |
| Padding | --space-6 block, page gutter inline via .container |
| Entry | rises 100% over --duration-base, --ease-out |
| Dialog width | min(38rem, 100vw − 2 × --space-4) |
| Checkbox | 20 px square, 1 px contour, foil fill when checked |
| Stacking | --z-overlay, below --z-modal; the dialog is in the browser's top layer and needs no z-index at all |
Hooks
| Attribute | Does |
|---|---|
data-cf-consent-banner | marks the first layer |
data-cf-consent-dialog | marks the settings <dialog> |
data-cf-consent-action="accept" | grants everything |
data-cf-consent-action="reject" | grants nothing beyond necessary |
data-cf-consent-action="save" | grants what the checkboxes say |
data-cf-consent-action="open" | opens the dialog — works anywhere, including the footer |
data-cf-consent-category="…" | binds a checkbox to a category |
data-cf-consent="…" on a script | gates it on that category |
cf:consent event | detail.granted — fires on load and on every decision |
<html data-cf-consent> | the granted categories, space separated, for CSS or debugging |
Stored under cf-consent in localStorage as
{ v, ts, categories }. The ts is there because consent has to be
demonstrable — but localStorage lives on the visitor's machine and proves
nothing to a supervisory authority. A production deployment still needs to log the
decision server-side. Bumping VERSION invalidates every stored answer, which
is what a changed set of purposes requires.
What the law asks for
Not legal advice, and the wording of the categories needs a lawyer's eye before launch. These are the requirements the component is built to satisfy.
| Requirement | How this meets it |
|---|---|
| Consent before storage (TDDDG § 25) | gated scripts are text/plain and never fetched until granted |
| Reject as easy as accept | identical buttons, same row, one click each |
| No pre-ticked boxes (Planet49) | optional categories ship unchecked; the stored default is nothing granted |
| Granular per purpose | one checkbox per category, saved independently |
| Withdrawal as easy as consent (Art. 7(3)) | the footer link reopens the same dialog |
| Informed | purpose, count and retention on every row; the policy linked from layer 1 and the entry-level inventory linked from layer 2 |
| No cookie wall | non-modal banner — the page stays readable and operable |
| Demonstrable | versioned record with a timestamp; server-side logging still required |
Accessibility
-
The banner is a non-modal
role="dialog". It carries noaria-modal, traps nothing and inerts nothing, because the page behind it must stay usable — a banner that blocks the site until you answer is a cookie wall. - It sits immediately after the skip link, so it is the second stop in reading and tab order and nothing moves focus to it. The banner used to focus its own heading on appearance, which cost the stop in front of it: the skip link is earlier in the document, so every forward Tab from inside the banner went further down the page and never back. Measured on the landing page, first visit, the skip link was 47 Tab presses away at 375 px and 52 at 1280 — the whole page — against 1 on a return visit with the decision stored. It is 1 again in both.
-
Nothing is lost by not moving focus. The banner is a fixed layer, so it is on screen at
whatever scroll position the page opens at; it is
role="dialog"and second in the document, so a screen reader arrives at it immediately after the skip link rather than instead of it; and this runs atDOMContentLoadedon a fresh navigation, where focus is at the start of the document and nobody has begun reading further down. Nothing is pre-selected either, which was the other half of the old reasoning and is still true: the reader arrives at the question, not at an answer. - Esc does nothing on layer 1, deliberately. Dismissal is not an answer, and a banner that vanishes on Esc either loses the question or quietly counts as consent. There is no close cross for the same reason.
-
Layer 2 is
showModal(), so it follows the APG modal dialog pattern natively: focus enters the dialog, Tab cycles inside it, the background is inert, Esc closes, and focus returns to whatever opened it. - Contrast on black, measured in the browser: heading and button labels 21.0:1, body copy and mono meta 10.6:1, button contours 10.6:1. The focus ring flips to lime, which is 18.5:1 here. The black tick on the foil ranges 11.9:1 to 16.4:1 across the ramp. Nothing in the component relies on colour alone.
-
Every control is a real
<button type="button">or a real checkbox, each at least 48 px tall. -
With JavaScript off the banner stays
hiddenand no gated script ever runs. The visitor is never asked a question that cannot be answered.
Rules
Do
- Keep reject and accept identical in size, weight and position.
- Name the purpose in plain German — what is measured, by whom, for how long.
- Put Cookie-Einstellungen in the footer of every page.
- Bump
VERSIONwhenever a purpose changes, and ask again. - Log the decision server-side as well; the browser record proves nothing.
Don't
- No lime here — the page underneath has already spent it.
- No frosted glass: the banner floats over content whose colour it cannot know.
- No pre-ticked boxes, and no category checked by default.
- No close cross, and no dismissal that counts as consent.
- Don't block the page. A consent notice is a question, not a gate.
- Don't load a third-party consent platform to do this. It is 5 kB.