Catalog · Site-wide
Cookie consent
Asks a visitor once whether the site may use optional cookies, with Accept and Reject side by side, and remembers the answer.
Fits best: any site — when the site uses optional cookies
Example
The cookie banner of a winery, with its two answers and the choice of which cookies to allow.
This component asks and records an answer. It does not make a site compliant: you decide what is stored, you write the information, and your scripts must obey the answer.
The example is live: until you answer it, it shows as a band at the bottom of this window, as it would on your site. Its answer is stored under its own key, luz-consent-demo, so it does not count as an answer for this site; the button above shows it again. This site lists that key on Privacy and storage.
Without JavaScript the banner and its button stay hidden.
Known issues
Without JavaScript the banner is not shown: none of the site’s own scripts run either, so there is nothing to answer. A server that sets optional cookies without a script must ask another way.
- The answer is a record in the visitor’s browser: a server cannot read it, clearing site data asks again, and it is no proof of consent (GDPR art. 7(1)); keep your own, e.g. from a
luz:consentlistener. - In a window under 30 rem (480 px) tall the banner is in the page flow, not fixed, so the page moves down when it appears.
- The saved state hides when focus leaves it, but not when you click an empty part of the page: use its Close button.
- It has not been tested with a screen reader.
Code
Copy the markup
import "@luz-ds/catalog/components/cookie-consent.css";
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@luz-ds/catalog@0.3.0/dist/components/cookie-consent.min.css" integrity="sha384-3DpxiYNab6T75OC8KW1K3HQokA+ljECduzmcDg+3OmGFTOOA0q90YnafFfrMkIvx" crossorigin="anonymous">
Right after the skip link, before a announcement and the header of the page (the site header or the masthead): it shows at the bottom of the window, but it comes first in the reading and Tab order. One checkbox per category of optional cookies, its value the category’s name.
catalog.min.js loads the banner’s own file, cookie-consent.min.js, when the browser is idle; a page without catalog.min.js loads that file on its own.
<script type="module" src="https://cdn.jsdelivr.net/npm/@luz-ds/catalog@0.3.0/dist/catalog.min.js"></script>
<a class="luz-skip-link" href="#main">Skip to main content</a>
<luz-cookie-consent>
<section class="luz-cookie-consent" data-luz-consent aria-labelledby="consent-title" hidden>
<div data-luz-consent-ask>
<h2 class="luz-cookie-consent__title" id="consent-title">Cookies on Bodega Albar</h2>
<p>We use cookies that this site needs to work. We would also like to count the visits
to each page, to decide which pages to update. <a href="/cookies/">Read the cookie policy</a>.</p>
<form class="o-actions">
<button class="luz-btn luz-btn--primary" type="submit" value="all">Accept optional cookies</button>
<button class="luz-btn luz-btn--primary" type="submit" value="none">Reject optional cookies</button>
</form>
<details class="luz-cookie-consent__choices">
<summary>Choose which cookies to allow</summary>
<form class="o-stack">
<fieldset class="luz-fieldset">
<legend>Optional cookies</legend>
<div class="luz-check-group">
<label class="luz-check-item"><input type="checkbox" value="analytics"> Measuring visits</label>
<label class="luz-check-item"><input type="checkbox" value="marketing"> Adverts on other sites</label>
</div>
</fieldset>
<div class="o-actions">
<button class="luz-btn luz-btn--secondary" type="submit">Save my choices</button>
</div>
</form>
</details>
</div>
<div data-luz-consent-done tabindex="-1" hidden>
<p>Your choice is saved. You can change it at any time on the
<a href="/cookies/">cookie policy</a> page.</p>
<button class="luz-btn luz-btn--secondary" type="button" data-luz-consent-close>Close</button>
</div>
</section>
</luz-cookie-consent>
<header>…</header>
<!-- On the cookie policy page -->
<button class="luz-btn luz-btn--outline" type="button" data-luz-consent-open hidden>Change cookie settings</button>
Your scripts read the answer and listen for a new one. Load an optional script only for a category the visitor granted:
import { getConsent } from "@luz-ds/catalog/cookie-consent";
const granted = (answer) => answer?.granted.includes("analytics");
if (granted(getConsent())) startMeasuring();
document.addEventListener("luz:consent", (event) => {
if (granted(event.detail)) startMeasuring();
// Withdrawn: the simplest correct answer is to reload the page
else location.reload();
});
Variants and options
There are no variants. The hooks are data-luz-consent-* attributes.
| Class or attribute | Effect |
|---|---|
.luz-cookie-consent | The banner: a band fixed at the bottom of the window, as wide as it, at most half its height and scrolling inside, its text in the header’s column, in the sans. In a window less than 30 rem tall it is in the flow instead. Keep hidden on it: the script shows it |
.luz-cookie-consent | Its heading, in the sans; it names the banner’s region through aria-labelledby |
.luz-cookie-consent | The <details> with the categories, under the two buttons |
<luz-cookie-consent> | The custom element the module defines. It wires the banner inside it when it connects |
data-luz-consent | On the <section>: the banner. Optional data-luz-consent-days, how long an answer lasts (180 days by default, 395 at most), data-luz-consent-key, the storage key (it must start with luz-), and data-luz-consent-policy, the version of the policy shown (32 characters of letters, digits, ., _ or -), stored with the answer |
data-luz-consent-ask, data-luz-consent-done | The question, and the confirmation that takes its place. The confirmation has tabindex="-1": it takes focus |
data-luz-consent-close | On a button inside the confirmation: hides the banner and returns focus to the button that opened it, or the first data-luz-consent-open button, or <main>. The confirmation also hides when focus leaves it for the page |
value="all", value="none" | On the submit buttons: Accept grants every category, Reject none. A submit button with another value, or none, grants the ticked ones of its form |
data-luz-consent-open | On a button anywhere, with hidden: its space is kept, the script shows it, and it shows the banner again, with the stored answer ticked |
--luz-consent-h | Set on :root by the script while the banner shows: its height. The page, .o-main and .luz-sidebar keep that much room at their end, and scroll what Tab reaches above the banner |
How it works
CSS from @luz-ds/catalog, two native forms and a file of its own, @luz-ds/catalog/cookie-consent (dist/cookie-consent.min.js, under 1 kB compressed), which defines <luz-cookie-consent>. catalog.min.js loads it when the browser is idle, and only on a page with a <luz-cookie-consent>, so no other page pays for it.
A banner added after the page has loaded needs the file imported by hand; two copies of it on one page define the element once.
- When it asks: when no answer is stored, when the stored one has expired, or when the banner offers a category the stored answer was not asked about. Then it shows, as a band at the bottom of the window, so nothing on the page moves: it traps nothing and takes no focus, and the page keeps room for it at its end and scrolls what Tab reaches above it, so the page can be read and used before answering.
- The answer: Accept grants every category, Reject none, Save my choices the ones ticked. Enter in a checkbox saves the ticked ones, never all. Escape is not an answer: nothing happens. The confirmation then takes the place of the question and takes focus, and is read. It goes away with its Close button, or when focus leaves the banner for the page; focus stays where it went.
- Where it is kept: in the browser’s
localStorage, underluz-consent, as JSON:{"version":1,"expires":…,"asked":["analytics","marketing"],"granted":["analytics"],"given":"2026-11-01T10:00:00.000Z","policy":"v2"}, withexpiresin milliseconds since 1970,giventhe time of the answer andpolicyonly when the banner has one. A new answer is merged into the record that is there: the categories this banner does not offer (an embed’s, say) keep their state. Not in a cookie: a cookie would travel to the server with every request, and asking about cookies should not need one. No personal data, and nothing leaves the browser. A malformed value counts as no answer. If storage is blocked, the answer holds for the page and the next page asks again. - Your scripts:
getConsent()returns the stored answer,{ asked, granted, expires }, ornull. Each new answer dispatchesluz:consentondocument, withdetail{ asked, granted, withdrawn, expires, given, policy }:withdrawnlists what was granted before and is not now, so you can delete what those categories stored. Category names are a lowercase letter, then lowercase letters, digits or hyphens, 32 at most. - Changing the answer: as easy as giving it. A button with
data-luz-consent-open, on the cookie policy page, shows the banner again. Put one banner on a page: every such button opens it. - The script writes no text: every word is in your markup, in the page’s language.
Without JavaScript
The banner and the button that shows it again stay hidden, by their hidden attribute. Without a script none of the site’s own scripts run, so there is nothing to ask about, and buttons that would do nothing are never shown.
Link the cookie policy from the footer of every page, so it can always be read.
Keyboard and ARIA
- Order: the skip link stays first, so a returning visitor can still jump to the content. The banner comes next, before the header, although it shows at the bottom: the policy link, Accept, Reject, then the button that opens the categories.
- Keys: Enter or Space on a button; Space ticks a checkbox. Escape does nothing: it does not hide the banner.
- Landmark: a
<section>named by its heading is a region, listed among the landmarks, before the banner landmark of the header. It is not a dialog: it does not block the page, so it needs norole="dialog". - Focus: none when the page loads. After an answer, the confirmation; after the button that shows it again, the banner. Close returns it to that button, or to the first such button, or to
<main>; never to the top of the page.
Contrast and focus are in the Design tab.
Design
When to use
Use it when your site sets cookies, or stores data in the browser, that it does not need to work, such as measuring visits or adverts: the law in the EU and the UK asks for the visitor’s consent before that.
When not to use
Do not use it when the site sets only the cookies it needs to work (a session, a basket) or stores only a choice the visitor made, such as a language or a colour, and uses it for nothing else: those need no consent. Say so in the cookie policy and show no banner.
- Do not use it as a wall that hides or blocks the page until the visitor answers.
- Do not use it for news or a sale: that is a announcement.
It replaces a third-party consent script. Nothing in LUZ is removed.
Do and don't
Writing the content
Say in a sentence what the site needs and what it would like, and why, in plain words. Name the heading after the site: “Cookies on Bodega Albar”.
- Write the buttons as the action and the thing: “Accept optional cookies”, “Reject optional cookies”; never “OK”, “Got it” or “Agree”.
- Name each category after what it does for whom: “Measuring visits”, “Adverts on other sites”, not “Performance” or “Targeting”.
- Link the cookie policy by its name, and say in the confirmation where the answer can be changed.
Contrast and focus
The banner is a muted band; its buttons and the categories are core’s.
| Part | Colour on background | Light | Dark | High contrast | Needs |
|---|---|---|---|---|---|
Heading and text | --luz-text-primary on --luz-surface-muted | 15.8:1 | 15.1:1 | 20.1:1 | 4.5:1 |
Link to the cookie policy | --luz-brand-primary on --luz-surface-muted | 5.6:1 | 7.7:1 | 13.5:1 | 4.5:1 |
Accept and Reject | --luz-text-on-fill on --luz-brand-fill | 6.0:1 | 4.9:1 | 14.1:1 | 4.5:1 |
Edge of Accept and Reject | --luz-brand-fill on --luz-surface-muted | 5.6:1 | 3.5:1 | 13.5:1 | 3:1 |
Categories and their button | --luz-text-primary on --luz-surface | 16.5:1 | 14.0:1 | 21.0:1 | 4.5:1 |
Checkbox edge | --luz-outline on --luz-input-bg | 4.5:1 | 3.4:1 | 21.0:1 | 3:1 |
Focus ring on the band | --luz-focus-ring on --luz-surface-muted | 5.6:1 | 7.7:1 | 20.1:1 | 3:1 |
Measured from the token values of each mode, rounded down. Needs is the WCAG 2.2 AA minimum: 4.5:1 for text, 3:1 for focus rings, edges and borders.
- Focus: core’s 2 px
--luz-focus-ringoutline on the link, the buttons and the checkboxes. - Accept and Reject look the same: neither is pushed by colour or size.
- A ticked category shows a check mark, not only a fill.
Tokens it uses
The 11 tokens that the rules of catalog’s components/cookie-consent.css read for the banner, by family; the buttons, the box of the categories and the checkboxes are core’s. Each family links to its page, with the values in every mode. In Figma they are variables with the same names: see Tokens in Figma.
- Spacing and sizing
--luz-space-xs,--luz-space-md,--luz-space-xl- Typography
--luz-font-sans,--luz-text-md,--luz-text-lg- Layers
--luz-z-toast- Layout and breakpoints
--luz-content-max
Keyboard and ARIA are in the Code tab.