All articles
Design & UXJuly 10, 2026 6 min read

Focus Rings Are a Design System Problem, Not a CSS Afterthought

Focus rings get slapped on at the end of a project, clash with the brand, and then get disabled. Here's how to treat them as a first-class design token — one that survives redesigns, dark mode, and QA.

Focus Rings Are a Design System Problem, Not a CSS Afterthought

Every accessibility audit we run finds the same bug in the first ten minutes: focus rings are missing, invisible on one background, or worse — someone shipped outline: none and moved on. It's not laziness. It's that focus styles almost never make it into the design system as a proper token, so they end up as a last-minute CSS patch that fails the moment the brand shifts.

This is a fixable problem, and fixing it once pays off across every button, input, card, and custom component you'll ever build.

Why focus rings keep breaking

The default browser focus ring is fine. It's ugly, but it's fine. The trouble starts when a designer opens Figma, doesn't see a focus state in the mockups, and the engineer either:

  1. Leaves the default ring, which then clashes with the brand once shipped.
  2. Removes it because it looks "off" on a colored button.
  3. Reinvents it per component, so your primary button, icon button, and text input all focus differently.

By the time WCAG 2.4.11 (Focus Not Obscured) and 2.4.13 (Focus Appearance) come up in an audit, you have twelve different focus treatments and nowhere obvious to fix them.

The root cause is treating focus as a visual polish item instead of a system-level primitive. It belongs next to your color and spacing tokens, not in a Figma "states" frame that nobody opens.

What a good focus ring actually needs to do

Before tokenizing anything, get aligned with your designers on what the ring has to accomplish. In our experience the non-negotiables are:

  • Visible on every background your components can sit on — light surface, dark surface, brand color, image overlay.
  • Distinct from hover so keyboard users can tell them apart from mouse users hovering.
  • Consistent shape across component types, even when the components themselves are wildly different (a chip vs. a data table row).
  • Never triggered by mouse clicks on buttons, unless the user Tab'd there. That's what :focus-visible is for.
  • Contrast ratio of at least 3:1 against adjacent colors, per WCAG 2.4.11.

The last one is where most systems quietly fail. A single blue ring at #2563eb will pass on white and fail on a dark blue hero section.

The two-layer trick

The fix that solves the contrast problem across arbitrary backgrounds is a double ring: an inner ring in your brand color, plus an outer halo in a contrasting neutral (usually white or near-black). One of the two will always have enough contrast, and together they read as intentional design rather than a hack.

.focus-ring {
  outline: 2px solid var(--color-focus-ring);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--color-focus-ring-halo);
}

On a white page, the halo disappears visually and you see a crisp brand ring. On a dark or brand-colored surface, the halo saves you.

Tokenizing focus in your design system

Once the visual language is agreed, put it in tokens. Not "focus-blue-500" — actual semantic tokens that describe intent.

{
  "color": {
    "focus": {
      "ring": { "value": "{color.brand.500}" },
      "ring-halo": { "value": "{color.surface.0}" },
      "ring-inverse": { "value": "{color.surface.0}" },
      "ring-halo-inverse": { "value": "{color.brand.900}" }
    }
  },
  "size": {
    "focus": {
      "ring-width": { "value": "2px" },
      "ring-offset": { "value": "2px" },
      "halo-width": { "value": "4px" }
    }
  }
}

The inverse variants matter. On a dark button or a photo card, you swap in ring-inverse and its matching halo. Now designers can pick "focus on light surface" vs. "focus on dark surface" in Figma without inventing new colors.

Wiring it into Tailwind

If you're on Tailwind, expose these as ring utilities in your config rather than sprinkling focus:ring-blue-500 everywhere:

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      ringColor: {
        DEFAULT: 'var(--color-focus-ring)',
        inverse: 'var(--color-focus-ring-inverse)'
      },
      ringOffsetColor: {
        DEFAULT: 'var(--color-focus-ring-halo)',
        inverse: 'var(--color-focus-ring-halo-inverse)'
      }
    }
  }
}

Then every component just uses:

<button class="focus-visible:ring-2 focus-visible:ring-offset-2">
  Save
</button>

When the brand shifts from blue to teal in the next redesign, you change one CSS variable and every focus ring in the product updates. That's the whole point.

:focus-visible is not optional in 2026

Browser support has been solid for years. There's no excuse for shipping a ring that appears every time a user clicks a button with their mouse — it looks like a bug, and it's the reason non-technical stakeholders ask you to "remove the blue outline."

The rule of thumb we use:

  • Use :focus-visible for buttons, links, and anything the user activates.
  • Use plain :focus for inputs, textareas, and comboboxes, because keyboard and mouse users both benefit from seeing the caret target.
button:focus { outline: none; }
button:focus-visible {
  outline: var(--size-focus-ring-width) solid var(--color-focus-ring);
  outline-offset: var(--size-focus-ring-offset);
}

input:focus {
  outline: var(--size-focus-ring-width) solid var(--color-focus-ring);
  outline-offset: 0;
}

Notice the inputs get zero offset. On a bordered input, an offset ring creates an ugly gap between the border and the ring. Get the offset right per component category — it's not one value for everything.

Custom components: the hard cases

Native elements are easy. The pain shows up in composite UI: segmented controls, custom selects, tab strips, tree views, drag handles, and anything built out of divs with ARIA.

A few rules that have saved us significant rework:

Focus the interactive element, not the wrapper

If you build a card that's clickable, put the focus ring on the anchor or button inside it, not on the outer <div>. Wrapping focus rings often clip against overflow: hidden parents, and they never feel right when the click target is smaller than the visual card.

Watch out for overflow: hidden

This one has bitten us more times than we'd like to admit. A rounded card with overflow: hidden will clip a box-shadow-based focus ring. Switch to outline (which is not clipped) or move the ring to an inner element with padding.

Grouped controls need a roving tabindex, not a ring per item

Radio groups, toolbars, and menus should have one tab stop. Inside, arrow keys move focus. If you slap a focus ring on every item and let Tab hit each one, keyboard users have to press Tab twenty times to leave a toolbar. Use tabindex="-1" on inactive items and manage focus with JS.

Testing focus without a QA team

You don't need a dedicated a11y audit to catch 80% of focus issues. Three cheap checks:

  1. Unplug your mouse for an hour. Navigate the whole app with Tab, Shift+Tab, Enter, Space, and arrow keys. Anywhere you lose track of where focus is, that's a bug.
  2. Screenshot every component's focus state in Storybook or Ladle. A visual regression tool will flag when a focus ring silently disappears after a refactor.
  3. Run axe or Lighthouse with focus-related rules enabled. They won't catch everything, but they catch the obvious outline: none regressions.

One trick we like: add a keyboard shortcut in development mode that outlines everything with :focus-visible for five seconds. It makes reviewing focus order in a PR ten times faster than tabbing through manually.

The dark mode gotcha

If you ship dark mode, your focus tokens need dark-mode variants — the halo color especially. A white halo that saved you on a dark brand button in light mode will now be invisible against your dark surface. Redefine the halo per theme, not per component. Otherwise you'll re-solve this problem inside every component that supports theming.

Where we'd start

If your product already has inconsistent focus styles, don't try to boil the ocean. Pick your three highest-traffic components — usually the primary button, the primary input, and the main navigation link — and rebuild their focus behavior with proper tokens and :focus-visible. Ship that, get everyone comfortable with how it looks, then roll the same tokens out component by component.

By the time you get to the exotic components (date pickers, kanban cards, drag handles), the pattern is settled and the arguments are over. That's the payoff of treating focus as a system primitive: you have the conversation once, and every future component inherits the answer.

If you'd like help auditing an existing design system or building one that treats accessibility as a first-class concern, our design and engineering team does this kind of work regularly.

#Accessibility#Design Systems#CSS#UX

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project