CreaTech
LAB-009CSS onlyAccessible

A focus ring worth looking at, with :focus-visible

A focus indicator that reads clearly on any background, animates in without being distracting, and never appears on a mouse click.

Built 18 Aug 2026 · Last verified 18 Aug 2026

The short version

A good focus ring is two rings, not one: an accent outline for visibility and a dark halo behind it so the contrast holds on any background. Using :focus-visible rather than :focus keeps it off mouse clicks while keeping it for every keyboard user, and animating outline-offset lets the ring land on the control instead of blinking onto it.

LAB-009live

How it works

  1. outline, not border and not box-shadow, is the base. It sits outside the box so it never changes layout, and since 2021 it follows border-radius on its own, which is the thing that used to send people to box-shadow.
  2. :focus-visible replaces :focus. The browser decides, using the same heuristic it uses for its own default ring: keyboard interaction shows it, a mouse click does not. This is one selector doing what used to take a library tracking input modality.
  3. outline-offset is animatable, so the ring starts six pixels out and settles to three. Movement toward the control reads as focus arriving on it, where a ring that simply appears reads as a flash.
  4. The second ring is a box-shadow in the page's own background colour, sitting between the control and the outline. On a light control an accent-only ring can fall below the 3:1 contrast WCAG 2.2 asks for; a dark halo guarantees it on any ground.
  5. outline-color transitions, not outline itself. Transitioning from outline: none gives you nothing to interpolate from, so the ring is declared transparent at rest and only its colour changes.
  6. The same three declarations cover a button, a link and an input. A focus style that needs restating per control is a focus style that will be missed on one of them.

The two lines that started this

:focus { outline: none; }

That is the single most damaging pair of lines in front-end development. It appears in almost every CSS reset ever written, it is usually added because the default ring is ugly, and it makes a site unusable for anyone navigating by keyboard: they are still moving through the page, but nothing on screen tells them where they are.

The honest reason it happens is that the default ring genuinely was ugly, and for years the only alternative was showing it to mouse users too, which clients would reject. That trade no longer exists. :focus-visible ended it, and the result can look like the rest of your design system.

:focus-visible does the modality detection for you

The distinction is simple. :focus matches whenever an element has focus, including after a mouse click. :focus-visible matches only when the browser judges that a focus indicator should be shown, which in practice means keyboard navigation and not mouse clicks.

Crucially, the browser is using the same heuristic it uses for its own default ring. It already knew the answer; the selector just exposes it.

.control:focus-visible {
  outline-color: var(--accent);
  outline-offset: 3px;
}

Before this, matching the behaviour meant tracking keydown and mousedown in JavaScript, setting a class on <body>, and getting the edge cases wrong. The focus-visible polyfill exists for exactly that and is no longer needed.

Try it in the demo: Tab to the three controls, then click the same three. The ring only appears for the keyboard.

Use outline, and stop reaching for box-shadow

The received wisdom used to be that outline could not follow rounded corners, so people drew focus rings with box-shadow instead. That has not been true since 2021. Every current browser gives outlines the element's border-radius for free.

The distinction that still matters is layout. outline is drawn outside the box and takes no space, so it can never shift anything. box-shadow also takes no space, but any approach using border will, and a focus style that moves the page by two pixels is its own small usability problem.

Prefer outline. Restating a radius that already exists is exactly the kind of duplication that goes stale the day someone changes the radius.

Two rings, because one is not always enough

A single accent-coloured ring works until it does not. Put a --sky ring against the aluminium button in the demo and the contrast is marginal; put it on a white card and it can fail outright. WCAG 2.2's focus appearance criterion asks for a 3:1 contrast between the focused and unfocused states, and a light ring on a light control does not deliver it.

The fix is a second ring behind the first, in the page's own background colour:

.control:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  box-shadow: 0 0 0 5px var(--ink-900);
}

Now there is always a dark band between the control and the accent ring, so the indicator holds up on any background it lands on. This is the one place box-shadow earns its keep, and it is deliberately the inner ring, where the radius mismatch is least visible.

Animate the offset, not the outline

outline-offset is animatable, which opens a small and genuinely useful trick: start the ring further out and let it settle in.

.control {
  outline: 2px solid transparent;
  outline-offset: 6px;
  transition: outline-color 0.12s ease, outline-offset 0.18s ease;
}
.control:focus-visible {
  outline-color: var(--accent);
  outline-offset: 3px;
}

Motion toward the control reads as focus arriving on it. A ring that appears at its final size reads as a flash, and over a long tab sequence the difference between the two is the difference between following the focus and being startled by it.

Two details make it work. The outline is declared transparent at rest rather than none, because there is nothing to interpolate from none. And only outline-color and outline-offset transition, not outline as a shorthand, which would try to animate the width too.

Write it once

The same three declarations in this demo cover a button, a link and a text input, and that is the actual recommendation. A focus style defined per component is a focus style that will be missing from whichever component someone adds next week. Define it once, on a shared class or on :focus-visible globally, as this site does in its globals.scss, and the default is correct everywhere.

One thing never to do, even under prefers-reduced-motion: remove the ring. Reduced motion is a request for less movement, not less information. Here the ring stops travelling and appears at its resting offset. It never stops appearing.

The source

MIT licensed. Use it in anything, no attribution needed.

Read straight off the file the demo above imports, then highlighted at build time. What you are reading is what is running.

Demo.tsxtsx
import styles from "./Demo.module.scss";

/**
 * Nothing to wire up: the whole thing is :focus-visible. This stays a
 * component only so the Lab can mount every experiment the same way.
 */
export default function FocusRing() {
  return (
    <div className={styles.wrap}>
      <p className={styles.hint}>
        Press <kbd className={styles.kbd}>Tab</kbd>. Then click the same
        controls with a mouse.
      </p>

      <div className={styles.row}>
        <button type="button" className={`${styles.control} ${styles.solid}`}>
          Primary
        </button>
        <a href="#lab-demo" className={`${styles.control} ${styles.ghost}`}>
          A link
        </a>
        <input
          className={`${styles.control} ${styles.input}`}
          type="text"
          defaultValue="An input"
          aria-label="Sample input"
        />
      </div>

      <p className={styles.note}>
        The ring is the same on all three, and it never appears on a click.
      </p>
    </div>
  );
}
Demo.module.scssscss
.wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
  padding: clamp(24px, 5vw, 40px) clamp(16px, 4vw, 28px);
  text-align: center;
}

.hint,
.note {
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.08em;
  color: var(--silver-500);
}

.kbd {
  font-family: var(--mono);
  font-size: 10.5px;
  padding: 2px 6px;
  border-radius: 5px;
  border: 1px solid var(--line);
  background: var(--ink-650);
  color: var(--silver-200);
}

.row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 12px;
}

.control {
  font-family: var(--sans);
  font-size: 14px;
  padding: 10px 18px;
  border-radius: var(--r-pill);
  border: 1px solid transparent;

  /* outline-offset is animatable, so the ring can land rather than appear.
     It starts wide and settles in: a movement toward the control, which reads
     as the focus arriving on it. */
  outline: 2px solid transparent;
  outline-offset: 6px;
  transition: outline-color 0.12s ease, outline-offset 0.18s ease;
}

/* :focus-visible, never :focus. That single choice is what keeps the ring off
   mouse clicks while keeping it for every keyboard user. */
.control:focus-visible {
  outline-color: var(--accent);
  outline-offset: 3px;
}

/* The second ring. An accent outline alone can fall below 3:1 against a light
   control; a dark halo underneath it guarantees the contrast on any ground,
   which is what WCAG 2.2's focus appearance rule is really asking for.
   box-shadow needs the radius restating; outline follows it for free. */
.control:focus-visible {
  box-shadow: 0 0 0 5px var(--ink-900);
}

.solid {
  background: var(--aluminium);
  color: #15151a;
  font-weight: 600;
  box-shadow: var(--shadow-1);
  cursor: pointer;

  &:focus-visible {
    box-shadow: 0 0 0 5px var(--ink-900);
  }
}

.ghost {
  background: var(--glass);
  border-color: var(--line);
  color: var(--silver-050);
  cursor: pointer;
}

.input {
  background: var(--ink-650);
  border-color: var(--line);
  color: var(--silver-050);
  min-width: 0;
  width: 130px;
}

@media (prefers-reduced-motion: reduce) {
  /* The ring still appears; it just stops travelling. */
  .control {
    transition: outline-color 0.12s ease;
    outline-offset: 3px;
  }
}

Questions worth answering

Does it work in Safari?

Every current browser. :focus-visible has been safe since 2022 and outline following border-radius since 2021. There is nothing experimental here, which is rather the point: this has been doable for years and is still usually done badly.

Does it need JavaScript?

None, and that is the improvement. Deciding whether to show a focus ring used to mean tracking keydown and mousedown in JavaScript. The browser now knows the answer and will tell you.

Is it accessible?

Reduced motion. The ring stops travelling and simply appears at its resting offset. What is never removed is the ring itself: a focus indicator is not decoration, and reduced motion is not a request to hide it.

Keyboard. This experiment is the keyboard case. Tab moves between a button, a link and a text input, all three showing the same indicator. Clicking any of them with a mouse shows nothing, which is the behaviour :focus-visible exists to give you.

Want this kind of care on a build?

I build fast, production-grade sites for freelance clients. The polish is the same; there is just more of it.