focus-outline-shape
The painted shape of a focus ring is determined by the focused element's own border box, its own `border-radius`, the offset/width pair, and every clip between that box and the screen — not by the com
Files1
View on GitHubSKILL.mdskills/focus-outline-shape/SKILL.md
GitHubFocus Outline Shape
A focus ring is not a component-level decoration. It is a paint operation on one specific box, and four independent inputs decide what lands on screen. Most "the focus ring looks wrong" bugs are not ring-styling bugs — they are wrong-box bugs.
The four inputs
- Which element is focused. The ring is drawn around that element's border box — content +
padding + border.
marginis not part of it. Not the visual card, not the enlarged hit layer, not the wrapper. - That element's own
border-radius. Modern browsers makeoutlinefollowborder-radiusautomatically, and grow the radius with the offset so the ring stays concentric. It reads the focused element's radius — never an ancestor's. outline-width+outline-offset. The ring occupies the band[border edge + offset, border edge + offset + width]. The offset positions the ring's inner edge; the ring always grows outward from there. Sooutline-3 outline-offset-0sits fully outside, and onlyoutline-3 -outline-offset-3sits fully inside.- Every clip between that box and the screen. Ancestor
overflow: hidden/clip/auto/scroll(rounded or not),clip-path, and masks all cut the ring. The ring is painted last but it is not painted above the clip.
Decide input 1 first. Then 2-4 are mechanical.
Rule 1 — Put focus on the element you want the ring around
The classic broken card: a rounded card clips its content, and the focusable thing is an inner box that fills the card. The inner box has no radius, so its ring is a sharp-cornered rectangle, and the parent's rounded clip removes exactly the four corner arcs. What survives is four straight segments with gaps at the corners.
// ❌ focus target is a square inner box inside a rounded clipper
<article className="relative overflow-hidden rounded-3xl">
<button className="absolute inset-0 focus-visible:outline-2 focus-visible:-outline-offset-1" />
{children}
</article>
// ✅ the focusable element IS the rounded card
<button className="overflow-hidden rounded-3xl focus-visible:outline-2 focus-visible:outline-offset-2">
{children}
</button>
// ✅ or keep the real control inside and lift the ring to the card
<article className="overflow-hidden rounded-3xl has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2">
<button className="focus-visible:outline-none">{children}</button>
</article>
An element's own overflow: hidden does not clip its own outline, so the card can clip content
and still draw a clean ring around itself. That is what makes the :has(:focus-visible) lift work.
When lifting, keep the semantics honest: the real interactive element stays a real button/a, only
the ring moves. Do not add tabindex to a wrapper to make the geometry easier.
Rule 2 — The ring reads the focused element's own radius
// ❌ square ring inside a rounded card
<div className="absolute inset-0 focus-visible:outline-2" />
// ✅ ring picks up the parent's curve
<div className="absolute inset-0 rounded-[inherit] focus-visible:outline-2 focus-visible:-outline-offset-2" />
rounded-[inherit] is the right default for any filling child that draws a ring, an inset stroke, or
a hover overlay. Do not hand-copy the parent's token (rounded-3xl on both) — it drifts the moment
the card's radius changes, and it is wrong anyway when the child is inset (see
nested-corner-geometry for the r_inner = r_outer − inset rule).
Do not hand-tune the ring's radius for the offset either. The browser already keeps the ring
concentric: a rounded-3xl (24px) card with outline-offset-2 paints a 26px ring, with
-outline-offset-8 paints a 16px ring.
Rule 3 — Spend the offset with the clip in mind
Two directions, two different requirements:
| Intent | Utilities | Requirement |
|---|---|---|
| Ring outside | outline-2 outline-offset-2 |
4px of unclipped space outside the border box |
| Ring flush inside | outline-2 -outline-offset-2 |
nothing — always safe inside a clipper |
| Ring inset by 2px | outline-2 -outline-offset-4 |
nothing |
Because the offset positions the ring's inner edge, a "slightly inset" ring like
outline-3 -outline-offset-1 still puts 2 of its 3 pixels outside the border box — inside a clipper
you get a 1px sliver and it reads as a thin, dim, broken line rather than a focus ring. Match the
numbers (-outline-offset-N with outline-N) whenever the ring must live inside a clip.
Rule 4 — Symptom decoder for clipped rings
| What you see | Cause |
|---|---|
| Four straight segments, corners missing | square-cornered ring + rounded ancestor clip (overflow-hidden + radius) |
| Ring missing on one edge only | scroll container edge — the ring is outside the scrollport on that side |
| Ring completely invisible | outset ring entirely outside the clip: ring-* (box-shadow) on a filling child, or a positive outline-offset inside a clipper |
| Ring invisible even with no ancestor clip | clip-path on the focused element itself — it clips the element's own outline |
| Ring bigger / rounder than the visible control | wrong box: padding or a wrapper is carrying the focus, not the visual box |
Two of those deserve emphasis:
clip-pathclips the element's own outline;overflow: hiddendoes not. Aclip-path-based rounded card silently kills its own focus ring. Useborder-radius+overflow: hiddeninstead, or move the ring to an unclipped ancestor.- Tailwind's
ring-*compiles to an outsetbox-shadow, which lives entirely outside the border box. Inside a clipper it does not degrade into a sliver — it disappears completely.outlineat least fails visibly, which is why it is the better default for focus.
Scroll containers
The overflow clip edge is the padding box, so vertical padding on the scroller buys room for an outward ring on the top/bottom edges — but a full-width row still loses its left/right sides. Either inset the ring, or pad both axes.
// ❌ first row's ring is clipped by the scrollport
<div className="h-40 overflow-auto">
<button className="w-full focus-visible:outline-2 focus-visible:outline-offset-2" />
</div>
// ✅ inset ring — nothing to clip
<div className="h-40 overflow-auto">
<button className="w-full rounded-md focus-visible:outline-2 focus-visible:-outline-offset-2" />
</div>
// ✅ or give the scrollport room on both axes and inset the rows
<div className="h-40 overflow-auto p-1.5">
<button className="w-full rounded-md focus-visible:outline-2 focus-visible:outline-offset-2" />
</div>
If you genuinely need an outward ring inside a clipping (non-scrolling) box, overflow: clip plus
overflow-clip-margin extends the clip region without giving up the clip:
<div className="overflow-clip rounded-3xl [overflow-clip-margin:6px]">…</div>;
This works only with overflow: clip — it has no effect on hidden, auto, or scroll.
Rule 5 — The ring is sized by the focus box, not the hit box
Enlarging a control with padding enlarges the ring. Enlarging it with an absolutely positioned hit layer does not — the focus box stays exactly the designed visual size.
// ❌ 24px icon, 40px ring
<button className="size-6 p-2 focus-visible:outline-2" />
// ✅ 24px icon, 24px ring, 40px hit area
<button className="relative size-6 rounded-md focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="absolute -inset-2" />
<Icon className="relative size-4" />
</button>
This is the geometric half of seamless-hit-target / expanded-touch-target: the visual box, the
focus box, and the hit box are three separate decisions, and only the focus box shapes the ring.
Rule 6 — Inline elements fragment; block-level ones do not
A non-replaced inline element that wraps across lines is several boxes, and the ring is painted per
fragment — a wrapped link shows two separate rectangles, not one connected shape.
box-decoration-break: clone does not merge them (it governs border/padding/background, not outline).
This is correct, expected behaviour for prose links — leave it alone. Do not "fix" it with
inline-block, which stops the link from wrapping mid-phrase and rewrites the paragraph's line
breaks. Reach for inline-flex only for things that must never break in the first place:
// ❌ turning a prose link into a block changes text flow to fix a cosmetic ring
<a className="inline-block focus-visible:outline-2">a long link inside a paragraph</a>
// ✅ prose link: accept per-fragment rings
<a className="focus-visible:outline-2 focus-visible:outline-offset-2">a long link inside a paragraph</a>
// ✅ chip / badge / tag: never wraps, so one ring by construction
<a className="inline-flex items-center rounded-full px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
<Icon /> label
</a>
Rule 7 — Transforms scale the ring, including its stroke
The outline is painted in the focused element's local coordinate space, so scale-150 renders a 2px
ring at 3px and pushes the offset out proportionally. If a control scales on hover/press, scale an
inner layer instead of the focusable element, so the ring keeps its designed weight:
// ❌ ring thickness animates with the button
<button className="transition-transform hover:scale-105 focus-visible:outline-2" />
// ✅ visual layer scales, focus box is stable
<button className="group relative focus-visible:outline-2 focus-visible:outline-offset-2">
<span className="block transition-transform group-hover:scale-105">{children}</span>
</button>
Diagnostic recipe
- Find the element that actually has focus —
document.activeElement, or DevTools':focus-visibleforce-state. Compare its box to the box you expected the ring around. - Read that element's
border-radius. Square ring on a rounded component means you are on the wrong box or missingrounded-[inherit]. - Walk every ancestor up to the root looking for
overflowother thanvisible,clip-path, ormask. Also check the element itself forclip-path. - Compare
outline-widthagainstoutline-offset. Ifoffset > -width, part of the ring is outside the border box and needs unclipped space. - Toggle to
-outline-offset-{width}as a probe. If the ring becomes whole, it was a clipping problem; if it stays broken, it is a radius or wrong-box problem.
Checklist
- The focusable element is the element whose shape you want the ring to have.
- Filling children that draw rings use
rounded-[inherit], not a copied radius token. - Inside any clipper, ring width and negative offset match (
outline-2 -outline-offset-2). - Hit areas are expanded with an absolute layer, never with padding on the focus target.
- No
clip-pathon anything that needs to show a focus ring. - Rows inside scroll containers use inset rings, or the scroller pads both axes.
- Scaling animations live on an inner layer, not on the focus target.
outlineis the default focus technique;box-shadow/ring-*only where an outset stroke is guaranteed unclipped space.
Anti-patterns
- Styling the ring before choosing the focus target — tuning radius and offset on a box that was never the right box.
focus-visible:outline-noneon the real control plus a decorative ring elsewhere with no:has(:focus-visible)link — removes the indicator for keyboard users when the decorative layer is clipped or conditionally rendered.- Copying the parent's radius token onto an inset child — drifts on redesign and ignores concentric geometry.
- Slightly-negative offsets on thick rings (
outline-4 -outline-offset-1) inside a clipper — leaves a 1px sliver that reads as a rendering artifact. clip-pathfor rounded corners on interactive surfaces — kills the element's own outline.- Padding as a touch-target fix on a focusable element — silently inflates the ring.
inline-blockon prose links to unify a fragmented ring — changes line breaking to fix something that was never broken.
Verified behaviour
Measured in Chrome 153 (outline: 3px/8px solid, screenshots compared per case):
- Ring band is
[edge + offset, edge + offset + width]: an 8px ring atoutline-offset: -2pxinside a clipping parent leaves a 2px sliver; at-8pxall 8px survive, flush inside. - Ring radius is
border-radius + outline-offset: aborder-radius: 24pxbox withoutline-offset: 8pxpaints a path that coincides exactly with a reference box inset by -9px withborder-radius: 32px. - Square-cornered ring inside
overflow: hidden+border-radius: 24px→ four straight segments, four corner gaps. Removing the parent'soverflowrestores the full square ring; addingborder-radius: inheritto the child restores the full rounded ring. - Outward ring (
outline-offset: 2px) on a child filling anoverflow: hiddenrounded parent → entirely invisible, even withborder-radius: inherit. Same setup withoverflow: clip; overflow-clip-margin: 6px→ fully visible. - Outset
box-shadowring on a filling child inside a rounded clipper → entirely invisible. clip-path: inset(0 round 24px)on the focused element → its own outline is clipped away.overflow: hiddenon the focused element → its own outline is unaffected.- Scroll container with unpadded rows: the first row's outward ring loses its top edge; vertical padding on the scroller restores top/bottom but not left/right for full-width rows.
- Wrapped inline link → two separate rectangles; unchanged by
box-decoration-break: clone. ::after-style hit layer atinset: -8pxon a 24px button → ring stays 24px;padding: 8pxinstead → ring becomes 40px.transform: scale(2)on the focused element → ring geometry and stroke width both double.