Focus Rings Are a Design System Problem, Not a Browser Default
Most teams ship focus rings by accident. Here's how to design them on purpose — with tokens, offsets, and a rule that survives dark mode, buttons on gradients, and picky designers.
Every design system has a color scale, a spacing scale, and a type ramp. Almost none of them have a focus ring scale — which is why keyboard users still get a mystery-meat blue outline on your $200k rebrand. Focus rings are a first-class design decision, and treating them like a browser afterthought is how you fail an accessibility audit two weeks before launch.
Why the default focus ring keeps losing
Browsers ship a reasonable default focus indicator. The problem is that designers hate it, so someone writes outline: none in a reset file, and the team forgets to put anything back. Or worse — they put back a 1px light-blue ring that vanishes on a light-blue button.
The usual failure modes we see in audits:
outline: noneon interactive elements with no replacement- A single focus color (usually brand blue) that fails contrast on half the surfaces it lands on
- Focus styles applied via
:hovercopy-paste, so the ring never appears for keyboard users - Ring styles that look fine on buttons but disappear on inputs, chips, and custom comboboxes
- Dark mode inheriting the light-mode ring, which then has 1.8:1 contrast against the new background
WCAG 2.2 tightened the screws here with Success Criterion 2.4.11 (Focus Not Obscured) and 2.4.13 (Focus Appearance). The short version: the focus indicator must be at least as large as a 2px perimeter of the component, and it needs 3:1 contrast against adjacent colors. That's not a browser problem. That's a design token problem.
Focus rings as tokens, not utilities
If your design system already tokenizes color and spacing, focus rings should slot in the same way. We usually define four things:
- Ring color — one per surface family, not one globally
- Ring width — typically 2px or 3px, tokenized so you can bump it later
- Ring offset — the transparent gap between the element and the ring
- Ring offset color — matches the surface behind the element, not the element itself
Here's the shape we like in a tokens file:
{
"focus": {
"width": { "default": "2px", "emphasis": "3px" },
"offset": { "default": "2px" },
"color": {
"onSurface": "{color.blue.600}",
"onSurfaceInverse": "{color.blue.200}",
"onBrand": "{color.white}",
"onDanger": "{color.white}"
}
}
}
Notice there's no single "focus color." There's a focus color per surface family. A ring color that works on white will not work on your primary CTA, and vice versa. Pretending otherwise is how you get 2.1:1 rings on gradient buttons.
The offset trick that fixes 80% of complaints
Designers hate focus rings because they look glued to the element. The fix is a transparent offset — a gap between the component and the ring, filled with the surface color behind it. This makes the ring feel like it's floating around the element instead of choking it.
In CSS:
.button:focus-visible {
outline: 2px solid var(--focus-color-on-surface);
outline-offset: 2px;
}
Or the Tailwind equivalent, which reads almost identically:
<button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600">
Save changes
</button>
Use outline instead of box-shadow. Outlines don't affect layout, they follow border-radius correctly in modern browsers, and they survive overflow: hidden on parents. Box-shadow rings are a legacy workaround from when Safari couldn't round outlines. That was 2022. Move on.
The :focus-visible rule everyone gets wrong
Here's the single most common bug: teams style :focus instead of :focus-visible. The result is that clicking a button leaves a glaring ring on it, which drives designers to strip focus styles entirely.
:focus-visible is the browser's heuristic for "this element was focused via keyboard or assistive tech, not a mouse click." It's been safe to use since 2021. The rule:
/* Wrong: fires on mouse clicks too */
.button:focus { outline: 2px solid var(--focus); }
/* Right: only fires when the user needs the hint */
.button:focus-visible { outline: 2px solid var(--focus); }
/* Safety net: make sure the default isn't lingering */
.button:focus:not(:focus-visible) { outline: none; }
The third rule is the safety net for older component libraries that still set :focus styles. You can drop it once you've cleaned house.
Contrast math you can't skip
WCAG asks for 3:1 contrast between the focus indicator and the adjacent colors — both the element itself and the background it sits on. In practice, that means checking two ratios for every ring:
- Ring color vs. element fill
- Ring color vs. surface behind the element
A blue ring on a white card behind a white page passes trivially. A blue ring on a blue primary button on a dark navy header is where things fall apart. This is why we recommend a two-tone ring for high-stakes components:
.button-primary:focus-visible {
outline: 2px solid var(--color-white);
outline-offset: 0;
box-shadow: 0 0 0 4px var(--color-blue-900);
}
Inner ring in white gives contrast against the blue button. Outer ring in dark blue gives contrast against whatever surface the button sits on. It looks intentional, and it passes 2.4.13 without a fight.
If you want to go further on the accessibility side, our team has written more about audit-driven fixes on the 72Technologies blog.
Custom components: where rings actually die
Native <button> and <input> get focus rings almost for free. The disasters live in custom components:
Comboboxes and listboxes
The input gets focus, but selection moves through options via aria-activedescendant. Your focus ring stays on the input, and you need a separate visual indicator on the active option — usually a background color plus a left border. Don't try to move the browser focus ring around; screen readers will get confused.
Cards as links
If a whole card is clickable, don't wrap the card in an <a> and call it done. Put the anchor on the heading, then use ::after with position: absolute; inset: 0 to expand the hit target. The focus ring lands on the heading, which is where it belongs semantically, and the card still highlights on hover.
Segmented controls and toggles
Each segment needs its own :focus-visible treatment, and the ring needs to work whether the segment is selected (filled) or unselected (ghost). This is where a per-state ring color token pays off.
Icon-only buttons
These are the easiest to break. A 32px icon button with a 2px ring at 2px offset ends up as a 40px target — which is what you wanted anyway for touch. Don't shrink the ring to keep the button small. Grow the button.
Testing without a screen reader marathon
You don't need a full assistive tech audit to catch 90% of focus ring bugs. Three checks:
- Tab through every page. If you lose the focus indicator at any point, that's a bug. Do this in both light and dark mode.
- Screenshot diff the focus states. Storybook + Chromatic (or Playwright's screenshot mode) can capture every component's
:focus-visiblestate and flag regressions. - Run axe or Lighthouse on interactive pages. They won't catch contrast on the ring itself, but they'll flag missing indicators.
For the ring contrast check specifically, we keep a small spreadsheet mapping every ring color to every surface it can appear on, with computed contrast ratios. Boring, but it's the artifact that survives a designer leaving the team.
Where we'd start
If you're staring at a codebase full of outline: none and a design system with no focus tokens, don't try to fix everything at once. Do this in order:
- Add
focus.width,focus.offset, and at least twofocus.colortokens (on-surface and on-brand) to your token file. - Replace every
:focusselector with:focus-visiblein your base component library. Ship it. Nothing else will break. - Audit your five most-used components — button, input, link, card, and whatever your primary nav item is — against the 3:1 contrast rule on every surface they appear on.
- Add a Storybook story per component that shows the focus state, so designers can review it without opening DevTools.
The payoff isn't just compliance. Focus rings done well signal that a product respects the people using it — including the ones not using a mouse. That's a design decision, and it deserves the same rigor as your type scale.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Figma Variables to Tailwind Tokens: A Workflow That Actually Survives Handoff
Design tokens die at the handoff boundary. Here's the Figma Variables → Tailwind pipeline we use so color, spacing, and radius changes make it to production without a designer filing a Jira ticket.

Empty States Are a Product Decision, Not a Design Afterthought
Most empty states are decorative apologies. The good ones sell the product, teach the mental model, and reduce support tickets. Here's how we design and build them without shipping cartoon mascots nobody asked for.

Skeleton Screens Are Lying to Your Users: When to Use Them and When to Stop
Skeleton screens became the default loading pattern, and most teams use them wrong. Here's when they help perceived performance, when they hurt it, and what to reach for instead.
