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.
How it works
- 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.
- :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.
- 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.
- 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.
- 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.
- 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.