Section 3 · Responsive and Robust
Focus Styles That Work
15 min4 min readCode playground
Lesson at a glance
Build focus indicators that keyboard users can always see, with :focus-visible, outlines that survive forced-colors mode, and scroll padding that keeps focus out from under a sticky header.
- 1:focus-visible: the ring when it helps
- 2Why outline beats box-shadow
- 3Contrast in both themes
- 4Focus a whole card
- 5Don’t let the header hide focus
- 6Your turn
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Open Waypoint’s schedule, put your mouse away, and press Tab. On the first version you’d see nothing. The reset contained :focus { outline: none; }, added because a designer disliked the ring that appeared when clicking buttons. Keyboard users, switch users and many screen-magnifier users navigate by focus, and they were navigating blind.
Removing focus styles isn’t a style choice; it’s a broken page for a large group of people. The good news is that modern CSS makes good focus styles easy to get right.
:focus-visible: the ring when it helps
The designer’s complaint was real: a ring after every mouse click looks like a glitch. :focus-visible solves it. It matches a focused element only when the browser judges that an indicator is useful:
- Focus arrived by keyboard: yes.
- A mouse click focused a button or link: no.
- A text field received focus, by any means: yes, because you need to see where you’re typing.
:root {
--focus: light-dark(#6d28d9, #c4b5fd);
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
}That one rule replaces the reset’s damage. It applies to every focusable element, keyboard users get a clear ring, and mouse users don’t see it on buttons. :focus-visible is Baseline widely available.
Why outline beats box-shadow
Many design systems draw focus rings with box-shadow because shadows used to follow rounded corners and outlines didn’t. Outlines have followed border-radius in all current browsers since 2023, and they have three advantages:
- No layout shift. Outlines don’t take up space, so focusing a button never nudges its neighbours.
outline-offsetputs a gap between the element and the ring, which keeps the ring visible against the element’s own background.- Forced-colors mode. Windows high-contrast themes remove
box-shadowentirely but keep outlines, recoloured to a system color.
Contrast in both themes
A focus indicator needs at least 3:1 contrast against what’s around it (WCAG’s non-text contrast). A violet that works on white can disappear on a dark slate surface. Defining --focus with light-dark(), as above, gives each theme its own ring color from one token: a deep violet on light surfaces, a pale lavender on dark ones.
For components that sit on unpredictable backgrounds (the hero image, a colored track badge), a two-tone ring is safest: an outline in the focus color plus a contrasting halo just inside it:
.hero :focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
box-shadow: 0 0 0 2px var(--surface); /* halo between element and ring */
}Here the shadow is decoration on top of a real outline, so forced-colors users still get the outline.
Focus a whole card
Waypoint’s session cards are clickable: the title is a link, stretched to cover the card with a pseudo-element. The ring then appears around the title text only, which looks odd. Move it to the card with :has():
.session-card:has(a:focus-visible) {
outline: 2px solid var(--focus);
outline-offset: 4px;
}
.session-card a:focus-visible {
outline: none; /* the card shows the ring instead */
}Removing the link’s own ring is fine here, because the card’s ring replaces it whenever the link has visible focus. That’s the only kind of outline: none you should ship: one that’s covered by another indicator.
Don’t let the header hide focus
WCAG 2.2 added Focus Not Obscured: the focused element can’t be entirely hidden by content the author put on top, like a sticky header or a cookie banner. Waypoint’s header is sticky and 64px tall, and when a user tabs backwards, the browser scrolls the focused card to the top of the viewport, right under the header.
html {
scroll-padding-top: 64px;
}Scroll padding tells the browser that part of the scroll container’s viewport is covered. Focus scrolling, anchor links and scrollIntoView() all respect it. If the header’s height changes, store it in a custom property and use it in both places.
Your turn
The exercise page has the bad reset. Replace it with a :focus-visible ring, move the ring to the card when its link is focused, and add scroll padding for the 64px header. That finishes the responsive section; next, you’ll add motion, starting with transitions, keyframes and respect for reduced-motion settings.
Key takeaways
:focus-visiblematches when the browser decides a focus indicator is useful, typically keyboard navigation, so you can show a strong ring without showing it on every mouse click.- Use
outlinewithoutline-offsetfor focus rings; it doesn’t change layout, followsborder-radius, and stays visible in Windows forced-colors mode wherebox-shadowdisappears. - Define the focus color with
light-dark()so each theme gets its own ring color, then check it reaches at least 3:1 contrast against the surface in both. scroll-padding-topon the scroll container stops a sticky header from covering the element that just received focus.