All articles
Design & UXAugust 3, 2026 6 min read

Focus Rings Are Not Optional: Building Keyboard UI That Doesn't Look Like Garbage

Your designer hates the default focus outline. Your accessibility auditor demands it. Here's how to ship focus indicators that pass WCAG, respect the brand, and actually help keyboard users.

Every design system meeting eventually hits the same wall: someone opens DevTools, tabs through the prototype, and a bright blue browser outline appears around a button. The designer winces. Someone types outline: none into the CSS. Six months later an audit flags 40 WCAG failures.

Focus indicators are the single most-broken piece of accessibility in shipped web products, and they're also one of the easiest to get right if you treat them as a first-class part of your design system.

Why the default outline keeps losing

The browser default focus ring exists because it has to. It's the only way a keyboard-only user knows where they are on a page. Screen reader users have their own cursor; sighted keyboard users have exactly one signal, and that's the ring.

The problem is that the default outline: auto looks different in every browser, ignores your border radius, and clashes with almost every brand palette on Earth. So designers ask engineers to hide it. Engineers oblige. The site becomes unusable for anyone who can't hold a mouse.

The fix isn't to remove the ring. It's to design one that belongs to your product.

What WCAG 2.2 actually requires

WCAG 2.2 introduced Success Criterion 2.4.11 (Focus Not Obscured) and tightened 2.4.13 (Focus Appearance). The short version:

  • The focus indicator must be at least 2 CSS pixels thick around the component.
  • It must have a contrast ratio of at least 3:1 against the adjacent colours (both the component and the background behind it).
  • The focused element can't be completely hidden by sticky headers, cookie banners, or chat widgets.

That's it. There's no rule that says it has to be blue, dashed, or ugly. You have room to design.

The two-layer focus ring pattern

A single-colour outline breaks the moment your button sits on a background that matches it. Ship a focus button on a dark navbar and a light-blue ring vanishes into the background. The trick is to use two layers: an inner ring in your brand accent and an outer ring in a neutral that contrasts with anything.

.button:focus-visible {
  outline: 2px solid var(--color-focus-inner);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px var(--color-focus-outer);
}

With --color-focus-inner set to a brand accent and --color-focus-outer set to a translucent neutral (white with 40% alpha on dark surfaces, black with 40% alpha on light ones), the ring reads on every background you're likely to ship.

Use :focus-visible, not :focus

This matters more than most teams realise. :focus fires on mouse click too, which is why designers historically hated focus styles — clicking a button left a persistent ring. :focus-visible only fires when the browser thinks the user is navigating by keyboard (or another non-pointer input).

/* Wrong: shows ring on mouse click */
.button:focus { ... }

/* Right: ring only when it helps */
.button:focus-visible { ... }

/* Belt and braces: kill the default for mouse users */
.button:focus:not(:focus-visible) {
  outline: none;
}

All modern browsers support :focus-visible. If you're still shipping fallbacks for IE11, that's a different article.

Tokenising focus in your design system

One-off focus styles rot fast. By the third component, someone will use a slightly different offset, and by the tenth you've got 10 flavours of ring. Bake focus into your token layer from day one.

Here's a minimal token shape we've used on real design systems:

{
  "focus": {
    "ring": {
      "width": "2px",
      "offset": "2px",
      "color": {
        "inner": "{color.accent.500}",
        "outer-on-light": "rgba(0, 0, 0, 0.4)",
        "outer-on-dark": "rgba(255, 255, 255, 0.5)"
      }
    }
  }
}

Expose those as CSS custom properties and every component gets a consistent ring without any component author thinking about it.

Tailwind users

If you're on Tailwind, the ring-* utilities were built for this. The trick is to configure them once in your theme and use them everywhere.

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

Then every focusable element gets the same treatment:

<button className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2">
  Save changes
</button>

One class prefix, applied everywhere, and your ring lives inside the token pipeline.

The parts everyone forgets

Getting focus right on buttons is table stakes. The wins come from the components most teams neglect.

Cards and clickable rows

If a whole card is a link — very common in dashboards, marketplaces, admin UIs — the focus ring needs to wrap the card, not the invisible anchor inside it. The pattern:

.card {
  position: relative;
}

.card a::after {
  content: '';
  position: absolute;
  inset: 0;
}

.card:has(a:focus-visible) {
  outline: 2px solid var(--color-focus-inner);
  outline-offset: 2px;
}

The pseudo-element makes the whole card clickable; the :has() selector moves the visible ring to the parent.

Custom checkboxes and radios

Nine times out of ten, the native input is opacity: 0 and a <div> is painted on top. That's fine — but the focus ring has to move with it. Put the ring on the sibling element using :has() or the classic adjacent-sibling trick.

.checkbox-input:focus-visible + .checkbox-visual {
  outline: 2px solid var(--color-focus-inner);
  outline-offset: 2px;
}

Skip links

Every multi-section page needs a "Skip to main content" link as the first focusable element. It stays visually hidden until it receives focus, then it appears. Screen reader users and keyboard users both benefit.

.skip-link {
  position: absolute;
  top: -100px;
  left: 1rem;
  transition: top 150ms;
}

.skip-link:focus-visible {
  top: 1rem;
}

Testing focus without becoming a full-time auditor

You don't need a huge tooling investment. Three habits catch most regressions:

  1. Tab through every new page before merging. If you lose the ring, or if it appears somewhere unexpected, fix it before the PR lands.
  2. Add a Playwright or Cypress test that tabs through critical flows (login, checkout, main dashboard) and asserts document.activeElement at each stop. This catches focus traps and hidden tabindex regressions.
  3. Run axe-core in CI. It won't catch every design nuance, but it catches missing focus styles and contrast failures on the ring colour itself.
// Playwright example
test('keyboard user can reach checkout', async ({ page }) => {
  await page.goto('/cart');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  const focused = await page.evaluate(() => document.activeElement?.textContent);
  expect(focused).toContain('Checkout');
});

The war story

We once inherited a fintech dashboard where the previous team had stripped focus rings from every interactive element "for design consistency". The compliance team flagged 200+ WCAG violations two weeks before a public launch.

The fix took four days, not four weeks, because the design system already used tokens. We added a single --focus-ring variable, wrote one :focus-visible rule that applied via a [data-focusable] attribute, and swept through the component library adding the attribute. Screenshots looked identical. Keyboard users could suddenly do their jobs. The launch shipped on time.

The lesson: focus indicators aren't a design tax. They're a small, tokenised piece of your system that pays for itself the first time someone with a broken mouse — or an assistive tech user, or a power user who lives on the keyboard — tries to use your product.

Where we'd start

If you're auditing an existing product this quarter, do these four things in order:

  1. Add a --focus-ring token pair (inner and outer) to your design tokens.
  2. Write one global :focus-visible rule that uses it, applied to button, a, input, select, textarea, [tabindex].
  3. Delete every outline: none in your codebase that isn't immediately followed by a replacement style.
  4. Tab through your three highest-traffic pages. Fix what you can't see.

That's a half-day of work for most codebases and it will remove more accessibility debt than any single change you can make to your UI. If you'd rather have someone else do the sweep, our design and engineering team does this kind of audit as a fixed-scope engagement — but honestly, most teams can knock it out themselves once the token is in place.

#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