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>
Reject and accept are the same button twice. Same size, same variant, same row, one click each. German supervisory authorities treat a prominent Accept beside a quiet Settings link as a dark pattern, and a reject that costs an extra click is not a valid choice architecture. Einstellungen is allowed to be the quiet one precisely because it is not the reject path — refusing is already possible without opening it.

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.

The list itself lives on the page that publishes it: Patterns · Datenschutz, section Cookies und Speicherung im Endgerät — the address /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

PartValue
Surface--surface-inverse — opaque, never frosted glass
Top edge--stroke-2, painted with --gradient-foil via border-image
Limenone — the page under the banner has already spent it
TitleGeist Mono, 11 px, uppercase, --tracking-wide
BodyGeist, 14 px, measure capped at 68 characters
Actionstwo .cf-btn--outline, one .cf-btn--ghost
Actions, under 900the 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
Entryrises 100% over --duration-base, --ease-out
Dialog widthmin(38rem, 100vw − 2 × --space-4)
Checkbox20 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

AttributeDoes
data-cf-consent-bannermarks the first layer
data-cf-consent-dialogmarks 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 scriptgates it on that category
cf:consent eventdetail.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.

Accessibility

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 VERSION whenever 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.