# Cookie banners that keyboard users reach last

Source: https://easeweb.dev/learn/cookie-banners
Topics: Dialogs and overlays > Cookie banners can be reached first and never trap focus (WCAG 2.4.3, 2.1.2, 2.4.11)
The fix: Put the banner where people meet it first. Either place it at the start of the page, before the header and in the page flow, so Tab and a screen reader reach it first, or open it as a native modal dialog with showModal() that Escape closes. Reserving the banner's height with scroll-padding keeps focus visible, but on its own it still leaves the banner last.
Test with: keyboard, screen reader, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html https://design-system.service.gov.uk/components/cookie-banner/ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

The home page of Harbour Museum has a header with four links (Visit, Exhibitions, Events, Shop), six links in the page (Opening hours, Book a guided tour, Family trail, Ship models gallery, Café menu, Access guide) and three in the footer (Contact, Press, Jobs). On a first visit, a cookie banner covers the bottom of the window: "We use cookies to count visits and improve the site", with Accept all, Reject all and Choose cookies. It is the first thing a sighted mouse user sees. Press Tab from the top of the page: focus goes through the menu, the page and the footer, and reaches Accept all on the fourteenth press. On the way, the links that scroll to the bottom of the window go behind the banner, focused but out of sight. A screen reader reading from the top reads the footer before it reads the banner.

## Why it fails

The consent script builds the banner when the page loads and adds it with `document.body.append()`, after the footer. CSS then fixes it to the bottom of the window. Where it is drawn and where it sits in the source have nothing to do with each other, and Tab and screen readers follow the source. Success criterion 2.4.3 asks that focus moves in an order that keeps the page usable. The banner is drawn in front of everything and covers part of every screen until it is answered, yet it comes after everything, so a keyboard user has to work through the page with part of it hidden before they can clear it. Success criterion 2.4.11 fails on the way: links that scroll under the banner are entirely hidden while they have focus.

## Who is affected

On a phone the banner can fill half the screen, so more of the page sits under it and more Tab presses pass behind it.

## What automation and AI miss

Each button has a name and the banner has text, so a scan of the page passes it. The order is only wrong compared with where the banner is drawn, and source-order checks do not compare the two. The banner also only appears on a first visit, without a saved choice: a scanner that keeps cookies, or runs after someone accepted, never sees it. Most banners come from a third-party consent platform whose code nobody on the team reads. Generated banner code tends to follow the common tutorial pattern: build the element, append it to the body, fix it to the bottom.

## Before: a banner added after the footer

```html
<body>
  <header class="site-header">…</header>
  <main>…</main>
  <footer>…</footer>
  <div class="cookie-banner">
    <p>We use cookies to count visits and improve the site.</p>
    <button type="button">Accept all</button>
    <button type="button">Reject all</button>
    <a href="/cookies">Choose cookies</a>
  </div>
</body>
```

```js
// The consent script builds the banner at load, then adds it after everything else.
document.body.append(banner);
```

```css
.cookie-banner {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
}
```

The banner is the last element in the body, so it is the last stop for Tab and the last thing a screen reader reads, while `position: fixed` draws it over the bottom of the window from the moment the page loads.

## After: the banner first in the page

```html
<body>
  <section class="cookie-banner" aria-labelledby="cookie-title">
    <div class="cookie-question">
      <h2 id="cookie-title">Cookies on Harbour Museum</h2>
      <p>We use cookies to count visits and improve the site.</p>
      <button type="button" value="accept">Accept all</button>
      <button type="button" value="reject">Reject all</button>
      <a href="/cookies">Choose cookies</a>
    </div>
    <p class="cookie-saved" tabindex="-1" hidden>
      Your cookie choice is saved. You can change it on the cookies page.
    </p>
  </section>
  <header class="site-header">…</header>
  <main>…</main>
  <footer>…</footer>
</body>
```

```css
/* Part of the page: it pushes the header down and scrolls away with it. */
.cookie-banner {
  position: static;
}
```

```js
const question = document.querySelector('.cookie-question');
const saved = document.querySelector('.cookie-saved');
for (const button of question.querySelectorAll('button')) {
  button.addEventListener('click', () => {
    saveChoice(button.value);
    question.hidden = true;
    saved.hidden = false;
    saved.focus(); // the next Tab goes on to the header
  });
}
```

The banner is the first thing in the body, so it is the first Tab stop and the first thing a screen reader reads, and the `section` with a heading gives it a name in the landmarks list. It sits in the page flow at the top instead of floating over it, so it cannot hide focus anywhere. After a choice, focus moves to the confirmation in the same place, so nobody is dropped at an unknown point. If a script builds the banner, add it with `document.body.prepend(banner)` instead of `append()`.

## After: a consent dialog, when the page waits for an answer

```html
<body>
  <dialog class="cookie-dialog" aria-labelledby="cookie-title">
    <h2 id="cookie-title">Cookies on Harbour Museum</h2>
    <p>We use cookies to count visits and improve the site.</p>
    <form method="dialog">
      <button value="accept">Accept all</button>
      <button value="reject">Reject all</button>
    </form>
    <a href="/cookies">Choose cookies</a>
  </dialog>
  <header class="site-header">…</header>
  <main>…</main>
  <footer>…</footer>
</body>
```

```js
const dialog = document.querySelector('.cookie-dialog');
if (!hasSavedChoice()) dialog.showModal();
dialog.addEventListener('close', () => {
  // Escape closes it without a value: nothing is saved, and it asks again next visit.
  if (dialog.returnValue) saveChoice(dialog.returnValue);
});
```

`showModal()` moves focus into the dialog, makes the page behind it inert, and closes it on Escape, so the banner is the first and only thing anyone can reach until they answer or dismiss it. The buttons in the `method="dialog"` form close it and set `returnValue`. Keep the dialog at the start of the body too: when it closes there is no button to return focus to, and browsers carry on with Tab from where the dialog sat.

## After: space reserved for a banner that stays last

```css
.cookie-banner {
  position: fixed;
  inset-inline: 0;
  bottom: 0;
}
html {
  scroll-padding-bottom: var(--banner-height, 0px);
}
```

```js
// The banner's real height, kept up to date as its text wraps or grows.
const root = document.documentElement;
new ResizeObserver(([entry]) => {
  root.style.setProperty('--banner-height', `${entry.borderBoxSize[0].blockSize}px`);
}).observe(banner);
// When the banner is answered and removed, give the space back.
function removeBanner() {
  banner.remove();
  root.style.removeProperty('--banner-height');
}
```

`scroll-padding-bottom` tells the browser that the bottom of the window is covered, so focus scrolling stops above the banner and no focused link hides behind it. This is the smallest change when a third-party script owns the banner and you cannot move it. It fixes only the hidden focus: the banner is still the last Tab stop and the last thing a screen reader reads, so people still go through the whole page before they can answer.

## Which option to use

The banner first in the page and the consent dialog both pass. The banner first is the smaller change: people can read and use the page and answer when they are ready, and nothing needs managing once it is in the right place. Its cost is that it pushes the page down on a first visit. The dialog makes everyone answer before using the page, and the browser handles focus, the inert background and Escape. Its cost is that it blocks the page for everyone, screen reader users included, before they have heard what the page is. Choose the banner first unless the page really has to wait for an answer. Reserving the banner's height on its own is a stopgap for a banner you cannot move: it keeps focus visible, but the banner stays last.

## When the banner comes from a third party

Most cookie banners are added by a consent management platform, so the markup is not yours to edit. Work through these in order, and stop at the first one that works:

- Check the platform's settings. Many let you choose the layout (a bar at the top, a bar at the bottom, a modal) and where in the page the banner goes. Choose a bar inserted at the start of the body, or a modal that closes on Escape.
- If there is no such setting, move the banner yourself once it appears: watch the body with a `MutationObserver`, and when the banner's element is added, move it with `document.body.prepend()`. Disconnect the observer after the move, and check that the banner's buttons still work, because some scripts rebuild the banner and put it back where it was.
- If the banner cannot be moved, reserve its height with `scroll-padding-bottom`, as in the last fix, so focus at least stays in view. Treat that as a stopgap, not the end of the work.
- Report the problem to the vendor, with the steps to reproduce it, and ask for their accessibility conformance report. Until it is fixed, list it as a known issue in your accessibility statement.

Check the banner for focus traps while you are there. They are common in vendor code, and they fail 2.1.2 (No Keyboard Trap):

- A banner that does not block the page, but whose script loops Tab inside it until a choice is made.
- A consent dialog that cancels Escape and offers only Accept as a way out.
- A "Choose cookies" panel that opens over the banner and, when closed, drops focus at the top of the document instead of returning it.

The `keyboard-traps` and `modal-focus-management` guides cover these in detail. Vendor scripts change without a deploy on your side, so repeat the steps under "Verify the fix" after each vendor update and each change to the platform's settings.

## Why not move focus to the banner

It can look like a shortcut: call `focus()` on the banner's first button when the page loads, and every keyboard user starts there. For a banner that does not block the page, don't. It is not a failure of a success criterion on its own, but placing the banner first in the source already makes it the first Tab stop and the first thing read, and forcing focus there takes away more than it gives:

- Screen reader users lose their bearings. When a page loads, a screen reader announces its title and starts at the top. Moving focus jumps straight to a button, often cutting the title off, so people hear "Accept all, button" without knowing which page they are on.
- People who use screen magnification have their view pulled to wherever the banner is drawn, often the bottom of the window, away from the top of the page they expected to see.
- Keyboard users who want to read first have to deal with the banner before anything else, instead of choosing when to answer it.

The exception is the consent dialog. When the page cannot be used until the question is answered, focus has to move into the dialog, and `showModal()` does that. Some consent platforms move focus to their banner by default: look for a setting that turns it off, and if there is none, include it in your report to the vendor.

## Implementation decisions

Put the banner before the skip link, as the GOV.UK Design System does. The first Tab then reaches the banner and the second the skip link, and once the banner is answered the skip link is first again. The same guidance advises against fixing the banner to the screen at all, because a fixed banner can cover focused content. If your design keeps it fixed to the bottom, keep it first in the source and reserve its height, as in the `focus-not-obscured` guide.

Render the banner in the server's HTML when you can. It is then in place before any script runs, and it does not push the page down after people have started reading.

## Verify the fix

1. Clear the site's cookies, load the page and press Tab once. Focus is on the banner's first control, or inside the consent dialog.
2. With the banner still showing, Tab through the page to the end and Shift + Tab back. No focused control is ever entirely hidden behind the banner.
3. Answer with each choice in turn, on fresh loads, and with Escape for a dialog. Focus lands on a confirmation or at the top of the page, never lost, and the choice is still applied after a reload.
4. With a screen reader on a fresh load, read from the top. The banner's heading and text come before the page's own heading.
5. Repeat at 200% zoom and in a 320-pixel-wide window, where the banner's text wraps onto more lines.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the consent platform or its settings change.

## Limits

This is a starting pattern, not a guarantee for every application. It covers how the banner is reached and what it hides, not what counts as valid consent: decide with whoever owns consent what each choice saves, and whether Escape may leave the question unanswered. Auditors differ on whether a banner placed last fails 2.4.3 on its own, but the focus hidden behind it fails 2.4.11 whenever it happens. Where Tab carries on after a banner or dialog is removed differs between browsers, so check it in the ones you support.
