Refactoring our cookie banner for better accessibility – Part 2

In the second article of our cookie banner refactor series, we focus on further accessibility improvements that made the banner and preferences modal more intuitive, consistent, and user-friendly.
João Rosa

Welcome back to Part 2 of our cookie banner accessibility refactor. In Part 1 we covered why accessibility matters, how we approached skip links, and why using the right HTML elements – like <button> instead of <div> – can make a world of difference.

In this part, we’ll go a step further. We’ll look at how we improved visual focus indicators, and tackled proper focus management inside modals while leveraging the <dialog> element to make the whole experience smoother and more inclusive. Let’s get started.

Visual focus indicators

By default, browsers add focus indicators – also known as "focus rings" – around interactive focused elements, like buttons and form inputs. These serve an accessibility purpose, so that users can easily see which element, if any, is currently focused. For the longest time it was common to forcefully remove these focus indicators, as they were deemed unaesthetic or conflicting with the intended design of the site. Fortunately, developers’ and designers’ sensibilities have evolved over time regarding the topic of accessibility, so it’s ever more common to find websites which don’t remove the default focus rings or even stylise them to match the site’s look and feel.

At first, almost no interactive element within the cookie banner nor the cookie preference modal had a focus ring, or any kind of focus indicator. This was not intentionally made this way within the styles of the cookie management solution but was rather an effect coming from the global styles of the website. We removed those styles, and now the original browser focus rings are shown in most instances.

There are some instances, however, where the browser styles are not proper or cannot be automatically applied. This is exactly what we faced with the custom checkboxes in the cookie preference modal.

In the video above you can see that the real checkbox <input> element is hidden away with CSS, and that instead we use a <span> to mimic the interaction with a checkbox. The real checkbox is still there and can be interacted with using the keyboard, and because everything is wrapped in a <label> element, clicking anything inside it will cause the real checkbox to be correspondingly ticked or unticked. However, because the <span> is a visual replacement for the checkbox element, it will not receive a browser’s focus ring on its own. To correct this, we used the following CSS:

.checkbox-container:has(input:focus-visible) .checkmark { 
  outline-offset: 5px; 
  outline: 5px auto Highlight; 
  outline: 5px auto -webkit-focus-ring-color; 
}
CSS for recreating the browser's own focus ring

Take a look at the selector. Notice how it's essentially saying "if the checkbox container has an input field which is supposed to have its focus highlighted, then use the following styles on the ".checkmark" container", which is the <span> holding the fake checkbox.

Except for the "outline-offset" property, the CSS above is a way to recreate the browser’s default focus outline, including the colour. Regardless of the selector, using these rules for the outline can serve you well for any element needing an outline when focused, but not when clicked with a mouse. Learn more about :focus-visible.
"Highlight" is a CSS system colour keyword that maps to the OS/browser-defined active selection colour. It's supposed to match the colour used by the OS for text selections and focus rings, like light blue in macOS or orange in Ubuntu. This value is recognised by Firefox and Chromium-based browsers like Chrome or Edge. On the other hand, "-webkit-focus-ring-color" is exactly what it sounds like: a WebKit-specific system colour typically used by Chromium-based browsers and Safari to show focus rings.

Why use both then? Well, Firefox doesn’t recognise "-webkit-focus-ring-color", and Safari doesn’t respect "Highlight". As for Chromium-based browsers, they typically recognise both keywords, but they result in different colours, and the browser's own user agent stylesheet uses "-webkit-focus-ring-color", so since we have to use both values, it's better to place "-webkit-focus-ring-color" after "Highlight" in order to maintain the consistency when resolving the colour in Chromium browsers. Also, keep in mind that the CSS above serves only to recreate the browser's own focus ring styles; it makes no decision regarding the actual colour used for the focus ring. To change that, given the inconsistencies between browsers and operating systems, simply changing the "outline-color" value is not enough in all instances, and you'd also need to set "accent-color" to the desired colour value.

Focus management in modals

A modal is an overlay interface that appears on top of the main page content, temporarily blocking interaction with the underlying page until the user completes a specific task or dismisses the modal. For accessibility, it's crucial that the keyboard focus is not only moved to the modal when it opens, but also remains trapped inside it for as long as it's open. In other words, if a user were to navigate with the "Tab" key while a modal is open, the focus should cycle through all the interactive elements within the modal and never focus on any element outside the modal until it’s closed. Historically, implementing proper focus trapping has been notoriously challenging for developers, requiring complex JavaScript to manually track focusable elements, handle edge cases, and do rigorous testing to make sure the existing logic had not broken from some other unrelated development work. This often results in inconsistent or broken experiences for keyboard and screen reader users.

The introduction of the <dialog> element fundamentally changed this landscape by providing built-in focus management and accessibility features that work reliably across modern browsers. Explaining how the <dialog> element works is beyond the scope of this article; instead let's just see the most important steps we took to replace a <div>-based modal solution with a proper built-in modal one. Let's first take another look at the original code:

<div id="cookie-customization-wrapper-backdrop"> 
  <div id="cookie-customization-wrapper"> 
    <button class="cookie-compliance-banner--button--cancel"> 
      <span class="sr-only">{{ 'Cancel'|t }}</span> 
    </button> 

    <div class="text-wrapper"> 
      <p class="banner-title">{{ 'This website uses cookies'|t }}</p> 
<!-- (...) -->

At the outermost level we have two nested <div> elements. The outer one serves as the backdrop, and its child serves as the modal container itself. On the HTML side, the change was very trivial: simply replace both <div> with a <dialog>, and give it an ARIA label. In our case, we decided to use the first inner text of the modal as our label, so instead of "aria-label", we used the "aria-labelledby" attribute with the value of a new "id" we also added to the desired text's container.

<dialog id="cookie-selection-modal" aria-labelledby="cookie-selection-modal-title"> 
  <button class="cookie-compliance-banner--button--cancel"> 
    <span class="sr-only">{{ 'Cancel'|t }}</span> 
  </button> 
  <div class="text-wrapper"> 
    <p class="banner-title" id="cookie-selection-modal-title">{{ 'This website uses cookies'|t }}</p> 

  <!-- (...) --> 

</dialog>

The rest is all handled on the JavaScript side. We had functions for opening and closing the modal. Below are the before and after of these functions.

// Before
function openCookieSelectionModal() { 
  addClass(cookieCustomizationWrapper, 'open'); 
  addClass(document.body, 'no-scroll'); 
} 

// After
function openCookieSelectionModal() { 
  cookieSelectionModal.showModal(); 
}


// Before
function closeCookieSelectionModal() { 
  // (...); 
 
  // Close the cookie selection modal 
  removeClass(cookieCustomizationWrapper, 'open'); 
  // Remove the no-scroll class from the body 
  removeClass(document.body, 'no-scroll'); 

  // (...) 
} 

// After
function closeCookieSelectionModal() { 
  // (...);

  // Close the cookie selection modal 
  cookieSelectionModal.close(); 

  // (...) 
}

Notice how the modal used to be opened and closed by toggling a CSS class, and how now one simply calls "showModal()" and "close()" on the dialog object. That's it. Simply calling these functions will make the browser take care of moving the focus between the outside and the inside of the modal accordingly, as well as informing assistive technologies when the user is inside a modal, among other accessibility goodies.  

Also notice how we used to toggle the class "no-scroll" from the document's body but not after the update. This was a utility class used to prevent the page from scrolling, since while on a modal, users are not supposed to be able to interact with the rest of the page, including scrolling. We removed this, because now the same behaviour can be implemented much more cleanly purely with CSS: 

body:has(dialog[open]) { 
  overflow: hidden; 
}
With just the lines above, the page will automatically no longer be scrollable once any <dialog> is open.

Using <dialog> as a modal offers other quality-of-life (and accessibility) improvements. By default, hitting the "Escape" key on a keyboard will cause the modal to automatically close – mind you, this only happens when the element is triggered using the "showModal()" function specifically. If needed, you can tap into this behaviour by adding an event listener to the dialog's "cancel" event. We did this because we needed to have some extra logic when the modal is closed, regardless of how this happens. While not something that happens by default, you can also close the modal when a user clicks its backdrop. Below is a simplification of how we do it.

/** 
 * This function will close the modal and, if the modal was originally opened 
 * from the cookie banner, will then open the cookie banner too. 
 */ 
function dismissModal() { 
  closeCookieSelectionModal(); 
  openCookieBanner();
} 

// Logic for cookie selection modal's "Cancel" and close ("X") buttons 
cookieSelectionModal.querySelectorAll('.cookie-compliance-banner--button--cancel').forEach(element => { 
  element.addEventListener('click', dismissModal); 
});

// Logic for cookie selection modal's "light dismiss" feature by clicking outside the modal 
cookieSelectionModal.addEventListener('click', ({ target: modal }) => { 
  if (modal.nodeName === 'DIALOG') { 
    dismissModal(); 
  }
});

// Logic for cookie selection modal's "light dismiss" feature by using the "Escape" key 
cookieSelectionModal.addEventListener('cancel', dismissModal);

Wrapping up

Accessibility is not a one-time task: it's a mindset. By reworking our cookie management interface with accessibility in mind, we didn't just tick boxes on a checklist; we made the experience smoother, more inclusive, and ultimately more professional. Users navigating with a keyboard or assistive technologies can now interact with the cookie banner and preferences modal much more easily and efficiently, without being hindered by poor markup or missing visual cues.

And while there's always room for improvement and a website is never truly fully accessible, this refactor was a significant step forward. What started as a compliance-driven necessity became an opportunity to genuinely improve user experience for everyone, including myself.

If there's a takeaway here, it's this: it's only ever getting easier to build accessible experiences in the browser. Modern browsers give us the tools, and accessible design benefits everyone. All it takes is a bit of care and attention to detail – and not reinventing the wheel when you don't have to. Sometimes, the best fix is simply choosing the right HTML element.

Our expert

João Rosa

Senior Frontend Developer

João Rosa started working at Cocomore in October 2015. Whether someone needs help with CSS or JavaScript, in bugfixing or developing in PHP – as a backend and frontend developer he is the “handyman” of our IT team. He loves the working atmosphere at Cocomore, because everyone is always super nice and helpful. Even in times when work is more stressful, everyone still finds the spirit to crack a joke and have a good laugh. Three words that describe the young Portuguese best: versatile, obstinate, witty.

Any questions or input? Reach out to our experts!

Send e-mail