Skip to content

Simpler cookie notices

It’s hard to browse the web without coming across the ubiquitous cookie notice these days. In fact, it’s become a source of annoyance for many people. Privacy regulations oblige countless website owners to ask their visitors for consent before using any cookies that aren’t necessary for the site to function. Generally, those are cookies used for tracking and targeting.

In this article, I’ll present a pattern for a simple cookie notice. I mostly aim to demonstrate the ease of the native dialog element and of the Cookie Store API; two recent or recently improved web features that greatly reduce our effort. In doing so, I’ll be playing devil’s advocate as I’d rather see all forms of tracking disappear, and with it the need for these notices. In other words, only use this if you must.

Permission for analytics?

Cookies used in website analytics are deemed non-essential. That means you need a consent notice that informs your visitors and gathers their explicit consent before setting such cookies on their device. For many websites, this is the only additional source of cookies, meaning we can be concise and specific in how we present the choice to the user. The term “cookie” acts as a placeholder for any similar technology here, so switching to Local Storage won’t relieve you from an explicit opt-in. In fact, let me discourage you one more time.

I believe there’s a strict hierarchy of options regarding tracking and analytics. Ranked from best to worst:

  • Don’t track anything. Soon, humanity shall unite in everlasting peace and prosperity.
  • Use privacy-friendly analytics like Fathom (referral link) or Plausible. These don’t use cookies and anonymise all collected data, so no consent banner is required.
  • Use traditional analytics like Google Analytics, but ask for consent before doing so.
  • Disregard your users and silently track them anyway. Your version of hell—or a lawsuit—awaits.

We’re concerned with the third option here. Let’s get to it.

The markup

As announced, we’ll use the dialog element. This native dialog implementation suffered a bunch of problems for a long time, but recent changes to the HTML specification have caused it to mature to the point of generally being the preferred option over rolling out a custom version. Accessibility expert Scott O’Hara—who’s been monitoring and testing the dialog for years—gave the green light back in January.

The markup is elegant and looks like this:

html
<dialog aria-labelledby="label" aria-describedby="description">
<h2 id="label">Cookie settings</h2>
<p id="description">
This website uses some essential cookies to make it work. We’d like to set additional analytics
cookies to collect usage statistics that help us improve the website. We won’t set these
additional cookies unless you accept them.
</p>
<form method="dialog">
<button value="accept">Accept</button>
<button value="reject">Reject</button>
</form>
</dialog>

The heading and paragraph text provide context to the dialog, especially when paired with the aria-labelledby and aria-describedby roles. The true novelty is the method="dialog" attribute on the enclosed form. Thanks to this, clicking the buttons will close the dialog without any JavaScript intervention.

The dialog’s closed by default, so you won’t see it at this point if you’re coding along. We’ll open it later with a touch of JavaScript, but for now we can make it appear by adding the open attribute to the dialog element, like so:

html
<dialog aria-labelledby="label" aria-describedby="description" open>
<!-- You might have to scroll to the right -->
<!-- The rest of our markup -->
</dialog>

Then we’ll add some styling.

Styling

We’ll style our cookie notice as a non-modal dialog that sits in the bottom right corner of the screen. The dialog being non-modal means you can still interact with the rest of the webpage.

css
dialog {
position: fixed;
bottom: 1em;
right: 1em;

overflow-y: auto;
box-sizing: border-box;
max-inline-size: min(30em, 100% - 2em);
max-block-size: calc(100% - 2em);
padding: 1em;
border: 0.15em solid #000000;
margin-inline-end: 0;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);

overscroll-behavior-block: contain;
}

dialog > * {
margin-block: 0;
}

dialog > * + * {
margin-block-start: 1em;
}

dialog > form {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}

The restrictions on max-inline-size and max-block-size ensure the notice doesn’t overflow the viewport on smaller screens and stays clear of the edges. By adding overflow-y: auto, the dialog becomes scrollable in the vertical direction if needed. If we’ve scrolled all the way down, overscroll-behavior-block prevents the rest of the page to start scrolling too. The remaining styles provide consistent spacing between the elements in our dialog. Adding some extra cosmetics, the notice now looks something like this:

Example

The bottom right positioning is clearer at full scale, though. Make sure to remove the open attribute before heading to the next step.

Opening and closing the dialog

Let’s get our cookie notice to work. On page load, we should check for any stored preferences. If the user previously consented to the use of tracking cookies, the analytics script can go ahead and run. If we don’t find any preferences, we’ll show the dialog so the user can make a choice. We’ll save that choice in a cookie.

js
const COOKIE_NAME = "cookie-settings";
const dialog = document.querySelector("dialog");

getCookie(COOKIE_NAME).then((cookie) => {
if (cookie) {
if (cookie.value === "accept") {
// Run analytics
}
} else {
dialog.show();

dialog.addEventListener("close", () => {
const value = dialog.returnValue;
setCookie(COOKIE_NAME, value);

if (value === "accept") {
// Run analytics
}
});
}
});

This is where the native dialog really starts to shine. Opening the dialog is as easy as calling the show() method—no fiddling with ARIA roles or classes. We then add an event listener for the close event. Remember how the “Accept” and “Reject” buttons automatically close the dialog through the method="dialog" attribute on the parent form. This will also trigger the close event. The returnValue property on our dialog is now set to the value attribute of whichever button we clicked.

As a last step, we should add the undefined getCookie and setCookie functions.

Old and new cookies

Getting and setting cookies the old way is surprisingly cumbersome. It involves weird string manipulation on the document.cookie property. Luckily, there’s the newer Cookie Store API with its simple get() and set() methods. Browser support isn’t that great yet, with notably Firefox and Safari missing, but we’ll use the old cookie methods as a fallback. Here’s the code for the missing functions:

js
async function getCookie(name) {
if (window.cookieStore) {
return cookieStore.get(name);
} else {
const value = document.cookie
.split("; ")
.find((row) => row.startsWith(name))
?.split("=")[1];
return value ? { value } : null;
}
}

async function setCookie(name, value) {
const expiration = new Date();
expiration.setMonth(expiration.getMonth() + 6);

if (window.cookieStore) {
return cookieStore.set({
name: name,
value: value,
expires: expiration,
});
} else {
document.cookie = `${name}=${value}; expires=${expiration.toUTCString()}; samesite=strict; secure`;
}
}

Now you may wonder why we go through the trouble of supporting two ways of getting and setting cookies, when Local Storage provides the same functionality with perfect browser support. The answer lies in the expiration date: items in Local Storage never expire. According to the GDPR, consent must be renewed at least once a year, and some national data protection hardliners like the French CNIL even recommend renewal every six months. Setting a cookie that expires ensures we comply with these regulations.

Here’s the result as a standalone or as an embedded version. The messages in the console will tell you if the settings cookie was found or not, and if analytics should run or not.

Example

Permission for even more things?

What if our website uses other non-essential cookies besides the ones used for analytics? Privacy laws stipulate that users should receive granular control over the types of data collecting they do or do not agree to. That means we have to adapt our cookie notice to include several categories. We’ll make use of checkboxes. Let’s have a look.

A bit more markup and styling

html
<dialog aria-labelledby="dialog-title" aria-describedby="dialog-description">
<h2 id="dialog-label">Cookie settings</h2>
<p id="dialog-description">
This website uses cookies. You can choose to allow or reject certain types of cookies hereunder.
More information about our use of cookies can be found in our <a href="#">privacy policy</a>.
</p>
<form method="dialog">
<div>
<label for="essential"><input type="checkbox" name="essential" id="essential" value="essential" aria-describedby="essential-description" checked disabled /> Essential cookies</label>
<p id="essential-description">
These cookies allow core website functionality. The website won’t work without them.
</p>
</div>
<div>
<label for="analytics"><input type="checkbox" name="analytics" id="analytics" value="analytics" aria-describedby="analytics-description" /> Analytics cookies</label>
<p id="analytics-description">
These cookies serve to collect usage statistics that help us improve the website.
</p>
</div>
<div>
<label for="youtube"><input type="checkbox" name="youtube" id="youtube" value="youtube" aria-describedby="youtube-description" /> YouTube cookies</label>
<p id="youtube-description">
These third-party cookies assign a unique identifier to your device. This helps Google to
collect viewing statistics and to show personalized advertising.
</p>
</div>
<button>Save preferences</button>
</form>
</dialog>

We’ve added a third cookie category to the mix: cookies set by YouTube embeds. Each category gets a checkbox that’s linked to a descriptive paragraph through the aria-describedby role on the input. The box corresponding to essential cookies is checked and disabled, as users can’t opt out of these. The “Accept” and “Reject” buttons are now merged into a single button that saves the user’s preferences. Notice how we link to the privacy policy—or possibly the cookie policy—of the website. That is where we should give a more elaborate overview of the types and purposes of the cookies we use.

We’ll add just a dash more CSS:

css
dialog > form {
display: flex;
flex-direction: column;
gap: 1em;
}

dialog > form label {
font-weight: 700;
display: flex;
align-items: center;
gap: 0.25em;
}

dialog > form p {
margin-block: 0;
}

Our cookie notice grew a bit bigger and now looks like this:

Example

I’ve added the “YouTube” category on purpose, to demonstrate yet another way to go. In my article on privacy-friendly video embeds, I showed how you can replace the YouTube video iframe with an overlay that prompts the user to consent to YouTube’s cookies before loading the video. I called this just-in-time consent, because we asked the user for consent only at the time it’s needed. For analytics, that means on page load of any page; but video embeds aren’t usually the first thing you land on when visiting a website. We could employ the overlay strategy for the videos, in which case we fall back to the very simple cookie notice we made before.

A bit more cookies

In this case, it’s best to store the preference for each category in a separate cookie. That way, we can query these preferences independently as needed. The alterations to our JavaScript code are pretty self-explanatory.

js
const COOKIE_NAME_ESSENTIAL = "cookies-essential";
const COOKIE_NAME_ANALYTICS = "cookies-analytics";
const COOKIE_NAME_YOUTUBE = "cookies-youtube";

const dialog = document.querySelector("dialog");
const analyticsCheckbox = dialog.querySelector("[name=analytics]");
const youtubeCheckbox = dialog.querySelector("[name=youtube]");

getCookie(COOKIE_NAME_ESSENTIAL).then((cookie) => {
if (cookie) {
// The user previously submitted preferences
// Go ahead and run analytics or load YouTube videos based on the values of the other cookies
} else {
dialog.show();

dialog.addEventListener("close", () => {
const analyticsValue = analyticsCheckbox.checked ? "accept" : "reject";
const youtubeValue = youtubeCheckbox.checked ? "accept" : "reject";

setCookie(COOKIE_NAME_ESSENTIAL, "accept");
setCookie(COOKIE_NAME_ANALYTICS, analyticsValue);
setCookie(COOKIE_NAME_YOUTUBE, youtubeValue);

if (analyticsValue === "accept") {
// Run analytics
}
});
}
});

And here’s the final result, as a standalone page and as an embed:

Example

Conclusion

If you must use a cookie notice, then it might as well be a lightweight and an accessible one. The native dialog element does a lot of the heavy lifting, and the Cookie Store API makes storing and retrieving cookies a breeze.