accessibilityfocusdesign-tokens

Focus outlines — the colour just has to be contrasty enough

A focus ring's only job is to be seen, which requires 3:1 contrast against the colours next to it (WCAG 1.4.11). Light, dark, or on-brand is beside the point — the ring sits between two colours and has to beat both.

It's one requirement, not a matter of taste

Three positions came up internally:

  1. A light ring, Bootstrap 4 style — a soft translucent glow.
  2. A fully black outline, Windows style — maximum contrast, no subtlety.
  3. A brand blue #1E64C8, splitting the difference — dark enough to see, not harsh.

These look like a debate about how a focus ring should look. They're not. A focus ring has exactly one job — to be seen — and one measure of whether it does it:

The visual presentation of [User Interface Components] have a contrast ratio of at least 3:1 against adjacent color(s).

WCAG SC 1.4.11 Non-text Contrast (Level AA)

That is the whole story. A ring is right when it clears 3:1 against the colours it sits next to, and wrong when it doesn't — light, dark, and on-brand are not the question. Every section below is that one number applied.

The ring sits between two colours

"Adjacent colour**(s)**" is plural on purpose. Because of outline-offset, a focus ring is seen against two colours at once:

  • the component it hugs on the inside, and
  • the surface beyond the offset gap on the outside.

"Contrasty enough" means enough against both of these. The rule is that simple to state: clear 3:1 against the neighbouring colours. The only real work is honouring it everywhere the ring appears — not just on the surface you happened to design against.

The light ring fails the test outright

This is Bootstrap 4's actual focus style, verbatim from bootstrap.css:

.form-control:focus {
  border-color: #80bdff;
  outline: 0;
  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.btn:focus, .btn.focus {
  outline: 0;
  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}

That ring at 25% opacity blends to #BFDEFF on a white page. Measured:

Comparison Ratio Needs 3:1
Ring vs white page background 1.39:1 fails
Ring vs grey #F8F9FA background 1.37:1 fails
Focus border #80BDFF vs default border #CED4DA 1.32:1 fails

It is not marginal. It is less than half the required ratio.

And there's a second, worse problem. Bootstrap 4 sets outline: 0 and puts the entire indicator in a box-shadow. In Windows High Contrast / forced-colors mode:

box-shadow is forced to none

MDN, forced-colors

outline survives forced-colors mode — only its colour is overridden. So Bootstrap 4 removes the one indicator that would have survived and replaces it with the one that gets deleted. For the exact users who most need a focus ring, there is no focus ring at all.

This is the argument that should end position 1. It isn't "the light ring is a bit subtle" — it's that the indicator vanishes entirely in high contrast mode.

Position 2 and 3: one colour is never enough

The obvious correction is a dark outline. But run the numbers across the surfaces a focus ring actually lands on, and no single colour passes everywhere:

Focus colour on white on grey on primary blue #0D6EFD on dark #212529
BS4 light ring #BFDEFF 1.39 ✗ 1.32 ✗ 3.24 ✓ 11.10 ✓
Blue-600 #2563EB 5.17 ✓ 4.90 ✓ 1.15 ✗ 2.98 ✗
Black #000000 21.00 ✓ 19.92 ✓ 4.67 ✓ 1.36 ✗

Read the last row carefully. Fully black fails on dark surfaces — 1.36:1 against a #212529 panel.

The light-versus-dark argument is a false choice. Any single-colour ring has a background it disappears against.

The brand blue, measured

The proposed middle ground is #1E64C8. Measured against every surface it can land on:

#1E64C8 against Ratio
White #FFFFFF 5.66:1
Light grey #F8F9FA 5.37:1
Mid grey #E9ECEF 4.77:1
The same brand blue — i.e. the primary button 1.00:1
Brand blue, darker #17509F 1.38:1
Dark surface #212529 2.73:1

On light backgrounds it is a good colour — comfortably past 3:1 without the harshness of black. That instinct was sound.

But #1E64C8 on a #1E64C8 primary button is 1.00:1. The ring and the button are the same colour. The focus indicator is not merely weak there, it is invisible — and the primary button is the single most-focused element in most products.

The 2.73:1 on dark surfaces is arguably worse than an obvious failure: close enough to 3:1 to look fine in a design review, still non-conformant.

outline-offset does not rescue this. The offset gap exposes the background on both sides of the ring, so a ring on a brand-blue button sits against brand blue regardless of the gap.

So the requirement stands: pick a ring colour that clears 3:1 against its neighbours. The brand blue does that on most of the product — it just doesn't on a couple of surfaces. Where a single value can't cover every surface, you give the failing surfaces a different value. Nothing more exotic than that.

Surface #1E64C8 White
White #FFFFFF 5.66 ✓ 1.00 ✗
Light grey #F8F9FA 5.37 ✓ 1.05 ✗
Mid grey #E9ECEF 4.77 ✓ 1.19 ✗
Brand blue #1E64C8 1.00 ✗ 5.66 ✓
Brand blue dark #17509F 1.38 ✗ 7.82 ✓
Dark #212529 2.73 ✗ 15.43 ✓

The blue keeps its 3:1 everywhere it looks good; on the two surfaces where it physically can't, a light ring clears it comfortably. This isn't a special "adaptive ring" technique — it's just the contrast requirement, met on each surface instead of only the one you designed against.

Try it

Tab through each row, or click a swatch to change the surface underneath. Contrast is computed live from the rendered styles.

Surface:
always passes — one tone always contrasts

Ratios are computed in-page from the swatch colours using the WCAG relative-luminance formula. Threshold is 3:1 per SC 1.4.11. The adaptive row picks whichever of brand blue / white contrasts best with the current surface.

Do — choose a ring colour that clears 3:1, and hold it there

The whole job is a colour that stays above 3:1 against its neighbours. On a system with one light surface, that's a single value and you're done. Where surfaces differ enough that no one value clears 3:1 on all of them, make the colour a token and give the exceptions their own value — the same requirement, met per surface rather than assumed.

:root {
  --focus-ring: #1e64c8;              /* brand blue — correct on light surfaces */
}

/* On surfaces the blue can't survive, the token flips to a light ring.
   Scope these to whatever marks a dark/brand context in your system. */
.surface-brand,
.surface-dark,
.btn-primary {
  --focus-ring: #ffffff;
}

:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;               /* gap sits the ring on the surface, not the fill */
}
  • One rule, many contexts. Components don't each hard-code a colour; they inherit --focus-ring from the surface they're on.
  • outline, not box-shadow, carries the ring — it is the only one that survives forced-colors (Windows High Contrast) mode.
  • outline-offset puts the ring on the surface behind the component, which is the colour the token was chosen against.
  • Use :focus-visible, not :focus, so mouse users don't get a ring on click while keyboard users still do.

Automating the choice — contrast-color()

Newly Baseline (April 2026), CSS can pick the contrasting colour for you:

:focus-visible {
  outline: 2px solid contrast-color(var(--surface-bg));
  outline-offset: 2px;
}

One caveat that decides whether it fits: contrast-color() returns only black or white — the earlier list-based color-contrast(), which could pick from your brand palette, was cut from the shipped spec. So it automates a monochrome adaptive ring, not a brand-coloured one. If a black-or-white ring is acceptable it removes the manual token overrides; if you want the ring to be brand blue on light surfaces, keep the per-surface tokens above. It is also very new — check your support floor.

Fallback for unknown backgrounds — the two-tone ring

The per-surface token works because you know the surface. When you don't — a focusable control sitting over a user-uploaded photo, a video, an arbitrary gradient — no single colour is safe. There, and only there, use a two-tone ring: a coloured outline plus a light halo, so one of the two always contrasts.

:focus-visible {
  outline: 2px solid #1e64c8;        /* survives forced-colors */
  outline-offset: 2px;
  box-shadow: 0 0 0 4px #ffffff;     /* light tone; allowed to vanish in forced-colors */
}

The box-shadow halo is progressive enhancement — if forced-colors strips it, the outline still stands. This is what Windows' own indicator does, and it's the reason it survives on any surface. For a busy mid-tone photo where even two tones struggle, back the ring with a small solid plate instead.

Don't

/* Removes the indicator with no replacement */
:focus { outline: 0; }

/* Replaces the durable indicator with the deletable one */
:focus {
  outline: 0;
  box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}

/* One FIXED colour everywhere — 1.00:1 the moment the surface is that same colour */
:focus-visible { outline: 2px solid #1e64c8; }

Why — the WCAG citations

  • SC 2.4.7 Focus Visible (Level AA) — a keyboard focus indicator must be visible. Bootstrap 4's outline: 0 plus a forced-colors-deleted box-shadow means no visible indicator at all for those users.

  • SC 1.4.11 Non-text Contrast (Level AA) — the operative one. Requires:

    The visual presentation of the following have a contrast ratio of at least 3:1 against adjacent color(s): User Interface Components and Graphical Objects

    This is the AA requirement the 1.39:1 light ring fails, and it is what makes this a compliance question rather than a preference.

  • SC 2.4.13 Focus Appearance (Level AAA) — adds a size floor on top of contrast:

    When the keyboard focus indicator is visible, an area of the focus indicator meets all the following: is at least as large as the area of a 2 CSS pixel thick perimeter of the unfocused component or sub-component, and has a contrast ratio of at least 3:1 between the same pixels in the focused and unfocused states.

    Note this is Level AAA, not AA. Worth stating plainly, because it's easy to over-claim in an argument — the 2px-perimeter rule is a target we choose to meet, not one we're obliged to. The 3:1 obligation comes from 1.4.11 above.

Careful with the numbering: in WCAG 2.2, 2.4.11 is Focus Not Obscured (Minimum) — a different criterion about sticky headers covering the focused element. Focus Appearance is 2.4.13. Earlier drafts numbered Focus Appearance as 2.4.11 at AA, and that stale citation is still widely repeated.

Still to confirm

The per-surface tokens above assume a complete map of surfaces. Before this ships, enumerate every named surface in the system and give each a --focus-ring value — the failure mode is a surface nobody tokenised falling back to the brand-blue default and silently failing on a dark panel. The measured #17509F and #212529 here are stand-ins; drop in the real dark and brand-hover tokens and the table regenerates.

Sources