
Focus Styles Done Right: :focus-visible and Keyboard Navigation
There's a moment in almost every project where someone clicks a button, sees a blue ring appear around it, and files a bug: "Weird outline on buttons after clicking." The quick fix, outline: none, makes the complaint go away and silently breaks the site for everyone who navigates with a keyboard. For years, developers felt forced to choose between focus rings that designers disliked and no focus rings at all. :focus-visible ended that trade-off.
In this guide, we'll look at how browsers decide when to show focus, how to design focus indicators that are both attractive and accessible, and how to handle the tricky cases: custom components, dark backgrounds, sticky headers, and forced colors mode.
Why Focus Indicators Matter
A lot of people navigate without a mouse:
- Keyboard users, including many people with motor disabilities, RSI, or tremors, and plenty of power users
- Switch device users, who move through the page one element at a time
- Screen magnifier users, who need to find where focus landed in a zoomed-in view
- Voice control users, who often combine spoken commands with keyboard-style navigation
For all of them, the focus indicator is the equivalent of the mouse cursor. Take it away and they're navigating blind. WCAG criterion 2.4.7 Focus Visible (Level AA) requires a visible indicator on every keyboard-focusable element, and WCAG 2.2 added 2.4.11 Focus Not Obscured so the focused element can't be hidden behind sticky content.
:focus vs :focus-visible
:focus matches an element whenever it has focus, however it got there. Click a button with the mouse, and it has focus. Tab to it, and it has focus. That's why styling :focus alone produces rings after mouse clicks.
:focus-visible matches only when the browser decides the user would benefit from seeing a focus indicator. The browser uses heuristics, and in practice they work like this:
- Keyboard navigation (Tab, Shift+Tab, arrow keys in some widgets) shows focus.
- Mouse or touch clicks on buttons and links usually don't.
- Text inputs always show focus, even on click, because the user needs to know where their typing will go.
- Focus moved by script (for example,
element.focus()when opening a modal) generally shows the indicator if the last user interaction was from the keyboard, and not if it was a pointer.
So this is the modern baseline:
:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 3px;
}
Browsers also apply their own default :focus-visible styles, which is why the "ring after clicking" problem mostly disappeared once browsers switched their defaults. If you're still seeing rings on click, check whether your CSS or a component library is styling :focus directly.
:focus-visible is supported in all current major browsers.
Removing Default Styles Safely
If you want to replace the browser's focus ring with your own, only remove it where you're adding a replacement:
/* Safe: the replacement is in the same rule */
.button:focus-visible {
outline: 3px solid #2563eb;
outline-offset: 2px;
}
/* If a library styles :focus, neutralize it only for pointer focus */
.button:focus:not(:focus-visible) {
outline: none;
}
That second rule was popular when :focus-visible was new, as a progressive enhancement. It's less necessary today, but it's useful when you're fighting a library that styles :focus unconditionally.
Designing a Good Focus Indicator
A focus ring doesn't have to be the default blue glow. It needs to be obvious, consistent, and high-contrast. The WCAG 2.2 Level AAA criterion 2.4.13 Focus Appearance gives a useful target even if you aren't aiming for AAA:
- The indicator area should be at least as large as a 2px perimeter around the element.
- The focused and unfocused states should differ by at least a 3:1 contrast ratio.
Use outline, Not Only box-shadow
outline is the best default for focus styles, for three reasons:
- It doesn't affect layout, so nothing shifts when focus moves.
- It follows
border-radiusin all current browsers, so rounded buttons get rounded rings. - It survives forced colors mode (Windows Contrast Themes), where
box-shadowis removed entirely.
.button {
border-radius: 999px;
}
.button:focus-visible {
outline: 2px solid #1d4ed8;
outline-offset: 3px;
}
outline-offset creates a gap between the element and the ring, which makes the ring stand out against the element's own background.
Two-Tone Rings for Any Background
A single-color ring can vanish against backgrounds of a similar color. If your site has both light and dark sections, a two-tone ring works everywhere:
:focus-visible {
outline: 3px solid #1d4ed8;
outline-offset: 2px;
box-shadow: 0 0 0 6px #ffffff;
}
The white shadow sits outside the offset outline, so the ring has a dark and a light edge. In forced colors mode, the shadow disappears but the outline remains.
Using a Custom Property for Consistency
Keep the ring consistent across components with tokens:
:root {
--focus-color: #2563eb;
--focus-width: 3px;
--focus-offset: 2px;
}
:focus-visible {
outline: var(--focus-width) solid var(--focus-color);
outline-offset: var(--focus-offset);
}
.theme-dark {
--focus-color: #93c5fd;
}
Now dark sections only need to change one variable.
Inset Focus for Tight Spaces
Items inside a scroll container, or cells in a dense table, can have their outline clipped by overflow: hidden on the parent. A negative offset draws the ring inside the element instead:
.menu-item:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: -2px;
}
:focus-within for Composite Components
:focus-within matches an element when it or any descendant has focus. It's perfect for highlighting a container when something inside it is focused.
Search Field with an Attached Button
<form class="search" role="search">
<label class="visually-hidden" for="q">Search articles</label>
<input id="q" type="search" placeholder="Search articles" />
<button type="submit">Search</button>
</form>
.search {
display: flex;
border: 2px solid #94a3b8;
border-radius: 8px;
}
.search:focus-within {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgb(37 99 235 / 0.3);
}
.search input {
flex: 1;
border: 0;
padding: 0.6rem 0.75rem;
background: transparent;
}
.search input:focus-visible {
outline: none; /* the container shows focus instead */
}
Removing the input's own outline is acceptable here only because the container clearly shows focus instead. Be careful to preserve that guarantee.
Keyboard-Accessible Dropdowns
A hover-only dropdown is unreachable by keyboard. :focus-within keeps the submenu open while focus is inside it:
.nav-item .submenu {
display: none;
}
.nav-item:hover .submenu,
.nav-item:focus-within .submenu {
display: block;
}
This is a decent baseline, but a proper disclosure pattern, with a button that toggles aria-expanded, is better for complex menus.
Combining with :has()
:has() (supported in all current major browsers) lets you style based on what kind of descendant is focused:
.card:has(a:focus-visible) {
outline: 3px solid var(--focus-color);
outline-offset: 4px;
}
.card a:focus-visible {
outline: none;
}
This pattern works well for cards that are entirely clickable through a single stretched link, where you want the whole card to show focus.
Keyboard Navigation Beyond Styling
Focus styles only help if focus goes somewhere sensible. A few CSS-related things affect that.
Keep Tab Order Logical
Focus follows DOM order. If you use order, flex-direction: row-reverse, or grid placement to rearrange interactive elements, the visual order and tab order can diverge, and focus appears to jump around the page. Rearrange the HTML instead when the content is interactive.
Don't Hide Focusable Elements with opacity
An element with opacity: 0 is still focusable. Keyboard users can tab into an invisible menu. Use display: none, visibility: hidden, or the hidden attribute for content that's collapsed, and the inert attribute to disable a whole region, like the page behind a modal.
.drawer[hidden] {
display: none;
}
.drawer:not(.is-open) {
visibility: hidden;
}
visibility can be transitioned, so you can pair it with an opacity fade and still keep hidden content out of the tab order.
Keep Focus Visible Under Sticky Headers
When a user tabs to a link near the top of the viewport, the browser scrolls it into view, and it can land right behind a sticky header. scroll-padding tells the browser to leave room:
html {
scroll-padding-top: calc(var(--header-height, 4rem) + 1rem);
}
This also fixes in-page anchor links that land under the header.
Skip Links
A skip link should be the first focusable element on the page and become visible on focus:
.skip-link {
position: absolute;
inset-inline-start: 1rem;
translate: 0 -150%;
padding: 0.75rem 1.25rem;
background: #0f172a;
color: #ffffff;
border-radius: 6px;
z-index: 1000;
}
.skip-link:focus-visible {
translate: 0 1rem;
outline: 3px solid #fbbf24;
}
Forced Colors Mode
In Windows Contrast Themes, the browser overrides your colors with a small system palette. It removes box-shadow and background images, and replaces outline colors with a system color. Focus indicators built only with shadows vanish.
The fix is simple: always include an outline, even a transparent one, since transparent outlines are painted in a visible system color in forced colors mode.
.chip:focus-visible {
outline: 2px solid transparent;
box-shadow: 0 0 0 3px #7c3aed;
}
@media (forced-colors: active) {
.chip:focus-visible {
outline: 3px solid Highlight;
}
}
Test by turning on a contrast theme in Windows settings, or by emulating forced-colors: active in Chrome or Edge DevTools under the Rendering panel.
Custom Widgets and Roving Focus
Composite widgets like tabs, toolbars, radio groups, and listboxes usually use a roving tabindex: only one item in the group is in the tab order (tabindex="0"), and arrow keys move focus between items. The JavaScript handles the movement, but the CSS still needs to show where focus is:
.tablist [role="tab"]:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: -4px;
border-radius: 6px;
}
.tablist [role="tab"][aria-selected="true"] {
border-bottom: 3px solid currentColor;
}
Keep the selected style and the focus style visually distinct. A keyboard user needs to know both which tab is active and which one they're about to activate.
Testing Your Focus Styles
- Put your mouse aside and press Tab from the top of each page.
- Can you always see where focus is? Is the indicator clear on every background?
- Does focus order match reading order?
- Can you open and close every menu, dialog, and disclosure widget?
- Is focus trapped inside open modals, and restored to the trigger when they close?
- Does anything get focus while it's visually hidden?
- Check again with forced colors on and at 200% zoom.
Conclusion
Good focus styles are one of the cheapest accessibility improvements you can make, and one of the most impactful. Use :focus-visible so rings appear for keyboard users without annoying mouse users. Build indicators with outline so they survive forced colors, and add a two-tone ring or custom properties for varied backgrounds. Use :focus-within and :has() for composite components, and make sure focus never lands on something hidden or behind a sticky header.
Then do the most important test of all: unplug your mouse for ten minutes and use your own site.


