Ch. 5 · HTML & CSS

CSS Focus Visibility and Keyboard Navigation

Why outline: none strands keyboard users, how :focus-visible decides when to show a ring, and focus styles that hold up on any background.

~6 min readintermediateupdated Oct 7, 2026

Open almost any web page, put the mouse away, and press Tab. On a well-built page a ring moves from link to link, and you always know where you are. On a page with *:focus { outline: none; } in its reset, focus still moves, but invisibly. You press Enter and something happens, and you’ve no idea what you just activated. For people who use only the keyboard (many motor-impaired users, screen magnifier users, and plenty of power users), that page is broken.

The rule outline: none usually arrives for a sympathetic reason: a designer clicks a button with the mouse, sees a blue ring around it, and files a bug. :focus-visible exists to resolve exactly that tension.

:focus versus :focus-visible

:focus matches whenever an element has focus, however it got there: Tab, a mouse click, a tap, or a script calling element.focus().

:focus-visible matches when the element has focus and the browser judges that the user would benefit from seeing it. The spec leaves the exact heuristics to browsers, but they agree on the main cases:

How focus arrived Button or link Text input
Tab / Shift+Tab matches matches
Mouse click doesn’t match matches
Touch tap doesn’t match matches
focus() from script depends on how the previous focus arrived matches

Text inputs match even on click, because anyone about to type needs to see which field will receive the text. For script focus, browsers generally carry over the last input modality: if the user was using the keyboard when the script moved focus (opening a dialog with Enter, say), the new element gets the visible ring; if they clicked, it usually doesn’t.

Browsers’ own default styles now use :focus-visible, which is why clicking a plain <button> in Chrome, Firefox or Safari no longer shows a ring, while tabbing to it does. The “ugly ring on click” complaint mostly comes from custom styles written against :focus.

Writing focus styles that work

The pattern is to style :focus-visible and leave :focus alone:

:focus-visible {
  outline: 3px solid #1d4ed8;
  outline-offset: 2px;
}
CSS

Walk through the parts:

outline rather than border because outlines take no layout space. Adding a 3px border on focus would push neighbouring content around by 3px each time focus moves; an outline is painted over the surroundings.

outline-offset: 2px moves the ring 2px outside the element, leaving a gap. That gap is what keeps the ring visible against an element with the same background colour as the ring, such as a blue button with a blue focus ring.

Aim for a contrast ratio of at least 3:1 against the adjacent colours (WCAG’s 1.4.11, Non-text Contrast). WCAG 2.2’s success criterion 2.4.13 (Focus Appearance, level AAA) sets a stricter target of an indicator at least as large as a 2 CSS pixel thick perimeter with 3:1 contrast between focused and unfocused states, and 2.4.7 (Focus Visible, level AA) requires that some visible indicator exists at all. A 3px solid ring in a dark blue meets both on white.

Outlines follow border-radius in all current browsers, so a pill-shaped button gets a pill-shaped ring. Older advice to use box-shadow for rounded rings dates from before that was true.

Surviving any background

A single blue ring disappears on a blue hero section and is barely visible on dark mode surfaces. Two approaches handle mixed backgrounds:

/* a two-tone ring: dark outline plus a light halo */
:focus-visible {
  outline: 3px solid #111827;
  outline-offset: 2px;
  box-shadow: 0 0 0 5px #ffffff;
}
CSS

The shadow paints a 5px white band around the element’s border box, and the outline sits over it, 2px out and 3px thick (2px to 5px from the edge). The result is a 2px white band next to the element, then a 3px dark line outside it. On a dark background the white band stands out; on a light one the dark line does.

/* or derive it from the component's own colours */
.button:focus-visible {
  outline: 3px solid currentColor;
  outline-offset: 3px;
}
CSS

currentColor follows the text colour, which already contrasts with the background the component sits on.

Don’t rely on box-shadow alone. In Windows High Contrast (forced-colors) mode, box shadows are removed, and a focus style made only of shadow vanishes. Outlines are kept and recoloured to the system highlight colour. If you must use a shadow, add outline: 2px solid transparent, which is invisible normally and becomes visible in forced colours.

Where focus rings get lost

Even with good styles, the ring can be hidden by layout:

  • overflow: hidden on a parent clips the outline of a child that touches its edge. Cards and carousels are the usual offenders. Add padding to the container or use a negative outline-offset on the child.
  • Sticky headers can cover the element that just received focus when the page scrolls it into view. scroll-padding-top on html, set to the header’s height, makes the browser scroll focused elements below the header. WCAG 2.2’s 2.4.11 (Focus Not Obscured) is exactly about this.
  • Custom controls built from divs never receive focus at all, so no style will show. Use real buttons and links, or at minimum tabindex="0" and the right role.
  • :focus-within misuse: styling a whole card on :focus-within is a nice extra, but if it’s the only indicator, the user can’t tell which of the card’s three links is focused.

Checking it

The test is simple and takes two minutes: unplug the mouse (or don’t touch the trackpad), press Tab from the address bar, and go through the page. At every stop, you should be able to point at the focused element. Also check that the order makes sense; focus jumping from the header to the footer and back means the DOM order and visual order disagree, often because of order in flexbox or explicit grid placement.

For dynamic UI, test the moments when focus moves on its own. Open a dialog with Enter: focus should go into the dialog and be visible. Close it with Escape: focus should return to the button that opened it, visibly.

In DevTools, Chrome’s Elements panel can force the :focus-visible state on an element (the :hov button), which helps when styling.

Interview practice

A team’s reset contains button:focus { outline: none; } because product managers complained about rings on click. Accessibility testing now fails for keyboard users. What change do you propose, and what about the custom dropdown that calls menuItem.focus() when the menu opens?

Answer

Remove the :focus rule and replace it with a :focus-visible style: button:focus-visible { outline: 3px solid …; outline-offset: 2px; }. Mouse clicks on buttons don’t match :focus-visible in any current browser, so the product complaint stays fixed, and keyboard users get a clear ring. Pick a colour with at least 3:1 contrast against the backgrounds the buttons sit on, or use a two-tone ring, and use outline so forced-colors mode keeps it.

For the dropdown, the script-driven focus follows the user’s last modality. If the menu was opened with the keyboard, the first item matches :focus-visible and shows a ring, which is what a keyboard user needs. If it was opened with a click, it typically doesn’t, which is fine for a mouse user. Either way, the menu items need their own :focus-visible style (custom items often reset their outline), and pressing Escape should close the menu and return focus to the trigger button. Test both paths: open by click, then press arrow keys (the ring should appear once the keyboard is used), and open with Enter.

Keep going

More in HTML & CSS

read ✓HTML & CSS · easy

HTML Buttons Versus Links for Actions

Links go somewhere and buttons do something. What each element gives you for free, and what breaks when a div or an a tag fakes the other.

~6 min readread →
read ✓HTML & CSS · hard

HTML Data Tables and Header Relationships

How th, scope, caption and headers/id tell a screen reader which headers describe a cell, with a two-level pricing table worked through.

~7 min readread →
read ✓HTML & CSS · easy

HTML Form Labels and Accessible Names

How label, for/id, aria-labelledby and aria-describedby give each field a name and a hint, and why placeholder text can't stand in for a label.

~6 min readread →
esc