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:
- A light ring, Bootstrap 4 style — a soft translucent glow.
- A fully black outline, Windows style — maximum contrast, no subtlety.
- 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-shadowis forced tonone
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.
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-ringfrom the surface they're on. outline, notbox-shadow, carries the ring — it is the only one that survives forced-colors (Windows High Contrast) mode.outline-offsetputs 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: 0plus a forced-colors-deletedbox-shadowmeans 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
- Bootstrap 4.6.2
bootstrap.css— focus styles quoted verbatim - MDN —
forced-colors - WCAG 2.2 Understanding docs — criteria above