foundations — focus

focus indicators

Thirty-three ways to say “this control has keyboard focus”, drawn on the same field so they can be compared rather than described. None of them is the house style yet — this page exists to choose one. The library ships the offset ring today, and it is in the grid below on the same terms as the other thirty-two.

Tab into a field rather than clicking it. That is the whole difference between the first two trigger settings, and it is invisible from a screenshot: :focus fires for the mouse too, :focus-visible is the browser’s judgement that a keyboard put you there. Always on holds every tile lit so the grid reads without thirty-three tab stops.
trigger
backdrop
state

baseline

01 variants

What the field looks like with the indicator taken away. Every tile below is read against this one.

Suppressed

Fails

We only use this to reply.

outline: none;

The most common focus bug in the world, and it is one line long. It is here so the rest of the page has a floor to be measured from.

No indicator at all. Keyboard operation of the field becomes invisible.

perimeter — outline

06 variants

`outline` does not affect layout, follows `border-radius`, and is the one perimeter that survives forced-colors mode. It is clipped by an ancestor's `overflow: hidden`, which is the reason a library reaches for a ring instead.

Browser default

Passes

We only use this to reply.

outline-style: auto;

Hands the drawing back to the browser: a black-and-white pair in Chrome, a blue glow in Safari, a dotted rect in Firefox. Free, never wrong, and never yours.

Each browser's default is built to clear 1.4.11 against any backdrop.

Flush outline

Passes

We only use this to reply.

outline: 2px solid var(--accent);
outline-offset: 0;

Sits directly on the border. Cheapest correct answer, and the one that reads as a thickened edge rather than as a ring.

2px at full opacity, 3.45:1 on light and 10.64:1 on dark.

Offset outline

Passes

We only use this to reply.

outline: 2px solid var(--accent);
outline-offset: 2px;

A band of backdrop between the border and the mark. The gap is what makes the same ink read as a ring around the field instead of a bloom leaking out of it.

2px at full opacity, with backdrop on both sides of the mark.

Inset outline

Passes

We only use this to reply.

outline: 2px solid var(--accent);
outline-offset: -4px;

Takes the gap out of the control rather than out of its neighbour. The answer for a cell in a grid or a row in a table, where an outward mark paints over the row above.

2px at full opacity against the field's own fill.

Dashed outline

Conditional

We only use this to reply.

outline: 2px dashed var(--accent);
outline-offset: 2px;

The desktop focus rect, and it still reads as temporary rather than as decoration — which is exactly what focus is. Out of place in a design system that has no other dashed line.

The dashes cut the perimeter's area roughly in half; 2.4.13 measures area, so it wants a thicker dash than a solid stroke.

Two-tone halo

Passes

We only use this to reply.

box-shadow: 0 0 0 3px var(--accent);
outline: 2px solid var(--fg);
outline-offset: 3px;

Accent inside, page foreground outside. Guarantees contrast against *any* backdrop, because one of the two always contrasts with it — which is why GitHub and macOS draw focus this way.

Contrast is structural rather than measured: one of the two rings always clears 3:1.

perimeter — box-shadow ring

08 variants

A ring composes with other shadows, animates, and takes as many layers as you like. It is also dropped entirely in forced-colors mode and clipped by an ancestor's `overflow: hidden` — so a ring alone is never the whole answer.

Flush ring

Conditional

We only use this to reply.

border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);

Border and ring in the same ink with nothing between them: 3px of continuous colour with no edge to say where the field stops. This is the shape that gets reported as the focus glow being too much.

Clears 1.4.11 on contrast and still reads as a bloom. The failure here is legibility, not measurement.

Offset ring

Passes

We only use this to reply.

border-color: var(--accent);
box-shadow:
  0 0 0 2px var(--backdrop),
  0 0 0 4px var(--accent);

The flush ring with a 2px band of backdrop pushed into the seam. quebi's own treatment — the same ink, moved 2px out, and that was the whole fix.

2px at full opacity with a backdrop band on the inside edge.

Inset ring

Passes

We only use this to reply.

box-shadow: inset 0 0 0 2px var(--accent);

The ring drawn inside the field. Nothing outside the control moves, so it is the form to use in a dense list or a table.

2px at full opacity against the field's own fill.

Soft glow

Conditional

We only use this to reply.

border-color: var(--accent);
box-shadow: 0 0 0 4px
  color-mix(in oklab, var(--accent) 25%, transparent);

Bootstrap's idiom: an opaque border carrying the contrast and a translucent spread carrying the emphasis. It works because the border is doing the accessible half.

The glow itself is ~1.3:1 and cannot be the indicator. It passes only while the opaque border under it is present.

Thick, low contrast

Fails

We only use this to reply.

box-shadow: 0 0 0 6px
  color-mix(in oklab, var(--accent) 22%, transparent);

The same ink spread over three times the area with no opaque edge under it. Prominent to the eye and nearly absent to a contrast meter.

~1.3:1 against the backdrop. 1.4.11 measures contrast, not how much of the screen you covered.

Animated ring

Passes

We only use this to reply.

@keyframes quebi-focus-ring-in {
  from { opacity: 0; transform: scale(1.04); }
}

The offset ring, arriving. 180ms is enough to be read as motion and short enough not to lag a Tab held down — and it is dropped entirely under `prefers-reduced-motion`.

Resolves to a 2px opaque ring; the animation only affects how it arrives.

Gradient ring

Conditional

We only use this to reply.

background: conic-gradient(…);
mask: linear-gradient(#000 0 0) content-box,
      linear-gradient(#000 0 0);
mask-composite: exclude;

A gradient cannot be a border colour, so this is a masked pseudo-element — an extra box on every field that uses it. Striking once, and hard to keep meaning anything when every state is a gradient.

Contrast varies around the perimeter. It passes only if the *lightest* stop clears 3:1, which is not how these get tuned.

Corner brackets

Fails

We only use this to reply.

mask:
  linear-gradient(#000 0 0) 0 0 / 14px 14px no-repeat,
  … three more corners;

Only the corners marked. Reads as a viewfinder, which is either exactly the register you want or completely the wrong one.

Four 14px corners are well under the area 2.4.13 asks for, and three of the four edges carry no mark at all.

border

07 variants

The cheapest family, and the one most likely to collide with something else: a border is usually already carrying the resting state, the hover state and the invalid state.

Border colour only

Fails

We only use this to reply.

border-color: var(--accent);

Nothing added, one property changed. It is also the same gesture as hover and as invalid, so on a field that has both, focus becomes the third meaning of one line.

A 1px stroke is under 2.4.13's area floor, and the focused/unfocused pair is a hue change rather than a 3:1 change.

Border thickens

Passes

We only use this to reply.

border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);

A real 1px → 2px border reflows the field, so the second pixel is an inset ring. Same drawing, no layout cost, and the substitution is the point rather than a shortcut.

2px at full opacity once the inset pixel is counted with the border.

Underline

Conditional

We only use this to reply.

box-shadow: inset 0 -2px 0 0 var(--accent);

Material's filled idiom. One edge, which is a different design language from a perimeter — mixing the two in one library is the usual way this goes wrong.

One edge of four. 1.4.11 is satisfied; 2.4.13's area floor is not, unless the bar is thicker.

Underline, growing

Conditional

We only use this to reply.

transform: scaleX(0) → scaleX(1);
transition: transform 200ms ease-out;

The same bar, arriving from the centre. The motion says which field you moved to, which is the one thing a static underline cannot.

Same geometry as the static underline; the transition changes nothing a meter can read.

Start-edge bar

Conditional

We only use this to reply.

inset-inline-start: 0;
width: 3px;

The nav and list idiom, placed with a logical inset so it stays on the reading edge in RTL. On a field it competes with the label, which is on the same edge.

3px on one edge clears 1.4.11; 2.4.13's perimeter area needs more than a single side.

Floating label

Conditional

We only use this to reply.

.label { top: 1.85rem → 0; }

Material's filled field: the label doubles as the placeholder and lifts clear on focus. It buys vertical space and spends it on a label that is missing until you look.

The lift is a position change, not a contrast change. It needs the border accent beside it to clear either criterion.

Notched outline

Conditional

We only use this to reply.

.label { background: var(--backdrop); }

Material's outlined field: the label lands on the border carrying a chip of backdrop, which is what cuts the notch. The only variant here that breaks the instant the backdrop colour is wrong.

Same as the floating label — the notch is geometry. The 2px border under it is what passes.

fill and depth

05 variants

Marks that live inside the control rather than around it. These are the ones that work in a dense list, where a neighbour is 1px away and there is nowhere to put a ring.

Background tint

Conditional

We only use this to reply.

background-color:
  color-mix(in oklab, var(--accent) 14%, transparent);
border-color: var(--accent);

The surface shifts a step. The only family that works where controls touch, which is why every menu and listbox in this library uses it for the focused option.

The tint is ~1.2:1 on its own. The border beside it is what carries 1.4.11.

Inverted

Passes

We only use this to reply.

background-color: var(--accent);
color: var(--on-accent);

Foreground and background swap — the System Highlight gesture. Unmistakable, and it takes the field's own text colour hostage, so the value has to be readable on the accent.

The whole control changes; the focused/unfocused pair clears 3:1 by a wide margin.

Elevation

Conditional

We only use this to reply.

box-shadow: var(--q-shadow-float);
border-color: var(--accent);

The field lifts. quebi's glow is mint light coming off the surface rather than a drop shadow, so it reads on the dark page and nearly vanishes on the light one.

A glow has no measurable edge. It passes here only because of the opaque border under it.

Scale

Fails

We only use this to reply.

transform: scale(1.03);

The field grows. It also moves its neighbours' apparent position, and it is removed entirely under `prefers-reduced-motion` — which leaves that reader with no indicator at all unless something else is drawn.

Size is not contrast, and the one mark it has disappears for anyone who asked for less motion.

Caret only

Fails

We only use this to reply.

caret-color: var(--accent);

The blinking caret is already a focus indicator — for a text field, on a sighted reader, at close range. It is here to show how much that leaves out: it is absent on every control that is not a text field.

A 1px blinking bar is neither a perimeter nor a state change the field itself reports.

satellite — the indicator is not on the control

04 variants

Something near the field changes instead of the field. Each of these is a genuine addition and none of them is sufficient alone, which is the reason to see them in the same grid as the ones that are.

Label accent

Fails

We only use this to reply.

.label { color: var(--accent); }

The label takes the accent. Useful as a second signal in a long form, where the ring tells you which field and the label tells you which question.

The indicator is not on the control, and text colour is 1.4.3's business rather than 1.4.11's.

Adornment highlight

Fails

We only use this to reply.

[data-slot="control"]::after {
  background-color: var(--accent);
}

A leading icon or trailing affix lights up. Fine as reinforcement, and it quietly assumes every field has an adornment to light.

A 8px dot is far under the area floor and says nothing about the control's boundary.

Description reveal

Fails

We only use this to reply.

.hint { opacity: 0 → 1; }

Help text appears on focus. It is a real pattern and a real trap: content that exists only while focused is unreachable by anything that is not the pointer or the keyboard caret.

Not an indicator at all. It also risks 1.4.13, which governs content that appears on hover or focus.

Whole-field ring

Passes

We only use this to reply.

.field:has(:focus-visible) {
  box-shadow: 0 0 0 4px var(--accent);
}

The ring wraps label, control and hint as one block. Right for a composite — a date range, a card of radios — and oversized for a single input.

2px at full opacity; the perimeter is larger than the control's but no less measurable.

system-driven

02 variants

Colours and properties the operating system owns. This is the family that still works when the user has replaced your palette with their own.

System Highlight

Passes

We only use this to reply.

outline: 3px solid Highlight;
outline-offset: 2px;

`Highlight` is a real system colour everywhere, not a forced-colors spelling — so this tile draws your OS selection colour right now. In forced-colors mode it is the only perimeter on this page guaranteed to survive: every box-shadow is dropped and every border colour is replaced.

The user's own high-contrast pair, by definition contrasting with their own backdrop.

accent-color

Fails

We only use this to reply.

accent-color: var(--accent);

Tints the glyph a browser draws for a native checkbox, radio or range. A text input has no such glyph, so this tile is identical to the suppressed one at the top — which is the fact worth seeing.

Inert on a text field. It is a control-appearance property, not a focus indicator.

what the verdicts are measured against

WCAG 1.4.11 Non-text Contrast (AA) asks the indicator to reach 3:1 against what is next to it. WCAG 2.4.13 Focus Appearance (AAA) adds a shape: at least the area of a 2px perimeter, and a 3:1 change between the focused and unfocused states. A badge here describes the drawing on this page, not the family in general — a border at 2px can clear both criteria where the same border at 1px cannot.

Colour is not the axis. --q-focus is the ink itself and clears 3:1 on both themes many times over, so everything marked Fails below fails on geometry or on translucency. Where a variant depends on something else being drawn beside it — the opaque border under a soft glow, the ring beside a floating label — the verdict says so, because that dependency is what breaks the first time someone simplifies it.

Two failure modes no badge can carry. A box-shadow is dropped entirely in forced-colors mode, which takes out the whole ring family; an outline is kept. And an outline or ring is clipped by an ancestor’s overflow: hidden, which is why an indicator inside a scroll container is usually drawn inset. The rules pages carry what this library enforces today.