EduFlow
Modern CSS Mastery
0%

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. 1:focus-visible: the ring when it helps
  2. 2Why outline beats box-shadow
  3. 3Contrast in both themes
  4. 4Focus a whole card
  5. 5Don’t let the header hide focus
  6. 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.
focus.css
: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:

  1. No layout shift. Outlines don’t take up space, so focusing a button never nudges its neighbours.
  2. outline-offset puts a gap between the element and the ring, which keeps the ring visible against the element’s own background.
  3. Forced-colors mode. Windows high-contrast themes remove box-shadow entirely 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:

CSS
.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():

CSS
.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.

CSS
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-visible matches 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 outline with outline-offset for focus rings; it doesn’t change layout, follows border-radius, and stays visible in Windows forced-colors mode where box-shadow disappears.
  • 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-top on the scroll container stops a sticky header from covering the element that just received focus.

Further reading

Check your understanding

3 questions

Question 1 of 3

Why is button:focus { outline: none; box-shadow: 0 0 0 3px violet; } a risky focus style?

Question 2 of 3

You want a focus ring on keyboard focus but not when a mouse user clicks a button. Which selector?

Question 3 of 3

Waypoint’s header is position: sticky and 64px tall. When users tab backwards through the schedule, the focused card scrolls into view hidden under the header. What fixes it?

Practice

Code playground

Bring back the focus ring

The prototype’s reset removes every focus outline. Replace it so that:

  1. Any element with visible focus gets a 2px solid outline in var(--focus) with an outline-offset of at least 2px (and no ring when it isn’t focused).
  2. When the card’s link has visible focus, the .session-card shows the same ring (you may hide the link’s own ring in that case).
  3. Focus scrolling stops below the 64px sticky header.
<header class="site-header">Waypoint Conf 2026 <button type="button" id="tickets">Get tickets</button></header>
<main>
  <article class="session-card">
    <h3><a href="#s1" id="card-link">Focus Styles That Work</a></h3>
    <p>Rings, offsets and forced colors.</p>
  </article>
</main>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • A focused button gets a 2px solid ring in the focus color
  • The ring has an offset of at least 2px
  • No ring when nothing is focused
  • The card shows the ring when its link is focused
  • Focus scrolling clears the 64px header