Accessibility · Keyboard and Focus

Focus Rings, :focus-visible, and the Heuristic Behind Them

Why browsers began suppressing focus indicators on mouse clicks, how the :focus-visible heuristic decides, and what a custom indicator must do to survive forced-colors mode and clipping.

The focus indicator is the only feedback a keyboard user gets about where input will go. For roughly two decades it was drawn unconditionally on every focused element, and then a large fraction of sites removed it with outline: none, because the ring appeared on mouse clicks where it looked like a rendering defect. The :focus-visible pseudo-class exists to resolve that conflict, and its resolution is a heuristic rather than a rule. Understanding what the heuristic keys on explains both when a custom style will fire and when it will silently disappear.

The conflict that produced a new pseudo-class

:focus matches whenever an element is the focused element, regardless of how it got there. A button clicked with a mouse matches. So does a text field clicked to type into it, where the ring is useless because the caret already shows the position. And so does a link that received focus as a side effect of something else entirely.

Authors removed the ring globally to suppress those cases, which removed it for keyboard users too. The accessibility consequence is severe enough to be a success criterion: WCAG 2.4.7 requires that any keyboard-operable interface have a mode of operation where the focus indicator is visible, and the failure mode is a user who presses Tab, sees nothing change, and cannot tell whether the page is responding.

:focus-visible splits the two populations. It matches when the user agent determines that the focus indicator should be shown, which in practice means: focus arrived from the keyboard, or the element is one where a visible indicator is always warranted regardless of input modality.

What the heuristic actually looks at

The specification deliberately does not fix the algorithm. It says the pseudo-class matches in the situations where the user agent determines an indicator should be shown, and leaves the determination to “a variety of heuristics”. No list of conditions is normative, so behaviour differs at the edges between engines and between versions. What the heuristics key on, observably:

  • Whether the interaction that moved focus was keyboard or pointer. Focus arriving from a key press, including a programmatic focus that follows one, generally matches.
  • Whether the element type gives its own visual position cue. An element that shows a caret is treated as already indicating focus.
  • Whether the element has already handled the key press. An arrow key used to move within a list does not, by itself, make the list’s items match.

The practical asymmetry is between buttons and text fields. Clicking a button with a pointer generally does not draw an indicator, because the pointer interaction itself located the target; clicking a text field does draw one, because the field needs input and the caret alone is a weak position cue. The exact behaviour is engine-defined, which is why a rule that styles :focus:not(:focus-visible) can look correct in one browser and leave a styled element unstyled in another.

Programmatic focus is the ambiguous case, and it is where widgets lose their indicator entirely. A script that focuses a dialog’s first field immediately after a keyboard activation will usually match, because the heuristic sees a recent keyboard interaction; the same script invoked from a mouse-driven path may not. Whether the call itself is treated as keyboard-initiated is implementation-defined. The focus() options do include a hint that the indicator should be shown, which settles the question where it is supported, but that option is recent enough in engines that a widget cannot rely on it alone.

/* Suppress the ring for pointer-driven focus only where the browser already decided it should be
   suppressed, and draw a custom ring where it should not be. */
:focus {
  outline: none;               /* never do this without the replacement below */
}

:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* Elements that must always show an indicator, whatever the input modality. */
[role='button']:focus,
[role='tab']:focus,
[role='menuitem']:focus {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

The first rule is the dangerous one and it appears in the snippet only to show the shape of the mistake. Removing the outline under bare :focus and restoring it under :focus-visible is safe only if every focusable element in the page can match :focus-visible when reached by keyboard. Elements that are only ever focused programmatically, in a pointer-initiated flow, can fall through the gap and end up with no indicator at all. Scoping the removal to :focus:not(:focus-visible) is equivalent and makes the intent legible.

Outline is the right property, and it is not a border

outline differs from border in three ways that all matter here. It does not participate in layout, so adding it does not shift content. It is drawn outside the border edge by default, which means it survives on an element whose border is already used for visual design. And it can be non-rectangular in principle, following the element’s shape in engines that support it.

A border used as a focus indicator moves adjacent content by the border width, causes a reflow on every Tab press, and is invisible on an element whose border colour already matches. A box-shadow indicator is not in the layout either, and is the usual alternative when the design needs a soft ring, but it is painted under the element’s descendants’ backgrounds in some stacking configurations and can be clipped by overflow: hidden on an ancestor.

Clipping is the failure that catches custom components. A focus ring drawn with outline on a control inside a container with overflow: hidden is clipped at the container edge, and on a horizontally scrolling row of tabs, the ring around the first tab is cut off on the left. outline is not clipped by the element’s own overflow in the way an inset box-shadow is, but an ancestor’s overflow does clip it. Moving the indicator inside with a negative outline-offset keeps it visible at the cost of drawing over the control’s own content.

Contrast is a separate constraint, and two criteria apply. Success criterion 1.4.11 (Non-text Contrast, AA) requires that the visual indicator of focus have a contrast ratio of at least 3:1 against adjacent colours. Success criterion 2.4.13 (Focus Appearance, AAA) goes further and specifies a minimum area — at least the equivalent of a 2 CSS-pixel thick perimeter of the component — together with a 3:1 contrast ratio between the focused and unfocused states. An outline in the accent colour on a background of the same hue passes neither. currentColor inherits the text colour, which is usually the only colour guaranteed to contrast with the background it sits on.

Forced colours and the disappearance of custom indicators

Windows high-contrast themes, exposed to CSS as forced-colors: active, replace author colours with a small system palette. Background images are removed, box-shadow is dropped, and background colours are overridden. An indicator drawn with a coloured box-shadow therefore vanishes entirely, and an indicator drawn with a background colour becomes indistinguishable from the surface.

outline survives, because outline colour is one of the properties the system palette remaps — it becomes the system’s highlight colour. This is a strong argument for using outline as the base mechanism and treating shadows and gradients as enhancement:

@media (forced-colors: active) {
  :focus-visible {
    outline: 2px solid CanvasText;   /* system colour keyword, remapped by the forced palette */
    outline-offset: 2px;
  }
  /* Any purely decorative indicator must be suppressed rather than left to be repainted. */
  .custom-ring {
    box-shadow: none;
  }
}

System colour keywords such as CanvasText, Highlight, and Canvas resolve to the user’s chosen palette in forced-colours mode and to sensible defaults otherwise, so a single declaration can cover both. Hard-coding a hex value inside the media query defeats the purpose.

Indicators on composite widgets

A composite widget — a tablist, a grid, a menu — has one tab stop and many focusable items, a pattern described in Roving tabindex and Active Descendant Compared. The indicator has two jobs there: show which item has focus, and show which item is selected. Those are different states, and conflating them means a user cannot tell whether arrowing has moved focus or changed the selection.

The conventional resolution is to draw the focus ring on the item that holds DOM focus, and to draw the selection with a separate visual treatment that does not depend on the ring, following the ordering rules in How the Browser Computes the Tab Sequence. In the active descendant pattern, where DOM focus stays on the container, the ring belongs on the container and the focused item must be indicated some other way — a background, a border, an underline — because :focus-visible will never match the item.

Whatever the mechanism, the indicator must be visible against every state the item can be in. A ring that is invisible on a selected tab, or on a hovered row, fails the requirement as surely as a missing ring does, and the combination of hover, selection, and focus is where most custom designs lose it. Testing each state in isolation misses exactly this.

References

  1. Selectors Level 4 — The focus-visible pseudo-class — W3C
  2. CSS Basic User Interface Module Level 4 — Outline — W3C
  3. Web Content Accessibility Guidelines 2.2 — Success Criterion 2.4.7 Focus Visible — W3C
  4. MDN Web Docs — :focus-visible — MDN