CSS Module 23 – Debugging, performance, accessibility, tooling and print
Accessible CSS: focus, motion, contrast and reflow
The WCAG 2.2 criteria that CSS controls: visible focus that sticky headers do not hide, reduced motion, contrast, reflow, text spacing and safe hiding.
What you will learn
- Apply the WCAG 2.2 success criteria that CSS directly controls
- Write CSS that hides content visually while keeping it available to screen readers, and the reverse
- Test a page with automated checks plus manual keyboard, zoom and forced-colours checks
- Prevent sticky headers from hiding the focused element, with scroll-padding
Before you start
What you’ll learn · Before you start
What you will learn
- Apply the WCAG 2.2 success criteria that CSS directly controls
- Write CSS that hides content visually while keeping it available to screen readers, and the reverse
- Test a page with automated checks plus manual keyboard, zoom and forced-colours checks
- Prevent sticky headers from hiding the focused element, with scroll-padding
Before you start
On this page
Many accessibility failures are CSS decisions. A rule removes the focus outline; a sticky header covers the field that has focus; an animation ignores the reader’s request for less motion; grey text is too faint to read; a fixed height cuts off text when someone needs more space between lines; a “hidden” label is hidden from the very screen reader it was meant for. Accessible CSS avoids each of those: keyboard focus that is visible and never hidden by the page’s own layers, motion that people can turn down, enough contrast for text, controls and focus rings, layouts that reflow at 320 CSS pixels and survive larger text spacing, and hiding techniques chosen for who should still get the content.
This lesson lists the WCAG 2.2 success criteria that CSS controls, measures six of them in Chromium 153 and Firefox 155, reads Chromium’s accessibility tree to show who still gets content hidden in each common way, and audits a sign-up page with axe-core 4.13.0, which found two of the page’s seven problems, flagged a third for review and missed the other four.
The success criteria CSS controls
| Criterion | Its name, and what it asks | What CSS does to it |
|---|---|---|
| 1.3.2, A | Meaningful Sequence: a correct reading order can be worked out | order, grid placement and flex-direction: row-reverse change what is seen, not the order read out |
| 1.4.1, A | Use of Color: colour is never the only way to tell things apart | Links inside text need an underline or 3:1 against the text; errors need more than red |
| 1.4.3, AA | Contrast (Minimum): text at 4.5:1, large text at 3:1 | Every text and background colour pair |
| 1.4.4, AA | Resize Text: text resizes to 200 % without losing content | Fixed heights and overflow: hidden on text |
| 1.4.10, AA | Reflow: no scrolling in two directions at 320 CSS px wide | Fixed widths, wide tables, long words |
| 1.4.11, AA | Non-text Contrast: controls, their states and graphics at 3:1 | Input borders, icons, focus rings |
| 1.4.12, AA | Text Spacing: no loss when spacing grows to the test values | Fixed heights on text, clipping |
| 1.4.13, AA | Content on Hover or Focus: extra content is dismissible, hoverable and persistent | Tooltips shown with :hover that vanish when the pointer moves onto them |
| 2.2.2, A | Pause, Stop, Hide: movement over 5 s that starts by itself can be stopped | Endless animations |
| 2.3.3, AAA | Animation from Interactions: motion caused by interaction can be turned off | prefers-reduced-motion |
| 2.4.7, AA | Focus Visible: the keyboard focus indicator is visible | outline: none without a replacement |
| 2.4.11, AA | Focus Not Obscured (Minimum): the focused element is not entirely hidden by the author’s content | Sticky headers, footers and banners |
| 2.5.8, AA | Target Size (Minimum): pointer targets of 24 × 24 CSS px, or enough space around smaller ones | Small icon buttons placed close together |
The AAA criteria 2.4.12 (no part of the focused element hidden) and 2.4.13 (a focus indicator of a minimum size and contrast) raise the bar further and are worth meeting where you can.
Focus you can see
Keyboard users need to see which element has focus. Browsers draw a focus indicator by default, and the most common
way to break it is a reset such as :focus { outline: none; }. Use :focus-visible for custom styles: browsers
match it when the focus should be shown, for example after Tab, and usually not after a mouse click on a button. A
focus ring is also a state of a control, so the Understanding document for 1.4.11 asks for 3:1 between the ring and
the colours next to it.
Draw rings with outline, not with box-shadow alone. In forced colours mode, such as Windows contrast themes,
the browser replaces the page’s colours with the user’s and computes box-shadow to none. Three buttons measured
in Chromium’s and Firefox’s forced-colours emulation:
focus-rings.css
/* Two focus rings that look the same with normal colours. */
.shadow-ring:focus-visible { outline: none; box-shadow: 0 0 0 3px #2563eb; }
/* An outline survives forced colours: its colour is replaced, but it is still drawn. */
.outline-ring:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
/* A transparent outline is invisible normally, and becomes a visible system colour in forced colours mode. */
.shadow-ring-safe:focus-visible { outline: 3px solid transparent; box-shadow: 0 0 0 3px #2563eb; } forced-colours.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Focus rings in forced colours mode</title>
<link rel="stylesheet" href="focus-rings.css">
<style>
body { margin: 0; padding: 16px; font: 16px/24px Arial, sans-serif; }
button { margin: 8px; padding: 8px 16px; border: 0; background: #e8eefc; color: #102a43; }
</style>
</head>
<body>
<button class="shadow-ring">Box shadow only</button>
<button class="outline-ring">Outline</button>
<button class="shadow-ring-safe">Box shadow with a transparent outline</button>
<script>
const forced = matchMedia('(forced-colors: active)').matches;
for (const button of document.querySelectorAll('button')) {
button.focus({ focusVisible: true });
const s = getComputedStyle(button);
const outline = s.outlineStyle === 'none' || s.outlineWidth === '0px' ? 'no outline' : `outline ${s.outlineWidth} ${s.outlineColor}`;
console.log(`${forced ? 'forced colours' : 'normal colours'} · ${button.className}: ${outline}, box-shadow ${s.boxShadow}`);
}
</script>
</body>
</html> | Focused button | Normal colours | Forced colours |
|---|---|---|
box-shadow only, outline: none |
a 3 px blue shadow | nothing at all |
outline: 3px solid #2563eb |
a 3 px blue outline | a 3 px outline in a system colour |
outline: 3px solid transparent plus box-shadow |
the blue shadow; the outline is invisible | a 3 px outline in a system colour |
The third pattern is the useful trick: a transparent outline costs nothing in normal colours, and in forced colours mode the browser paints it in the user’s colour, so the ring comes back. Chrome’s Rendering tab can emulate forced colours, as the DevTools lesson showed; test in a real Windows contrast theme before relying on it.
Focus the page does not hide
A sticky header is drawn over the content that scrolls under it, and browsers scroll a newly focused element into
view only when it is outside the viewport. An element under the header is inside the viewport, so nothing scrolls,
and the field the user is typing into may be hidden completely, which fails 2.4.11. This form has a 64 px sticky
header; the page is scrolled so that field 12 sits 8 px from the top of the window, and focus moves to it with
Shift+Tab and then with focus():
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A sticky header and the field that has focus</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
header { position: sticky; inset-block-start: 0; z-index: 1; block-size: 64px; background: #1d4ed8; color: #fff; }
form { padding: 0 16px; }
label, input { display: block; }
label { margin-block-start: 16px; }
input { box-sizing: border-box; inline-size: 100%; block-size: 40px; }
/* The fix: anything scrolled into view lands below the header, with 8px to spare. */
html.fixed { scroll-padding-block-start: 72px; }
</style>
</head>
<body>
<header>Checkout</header>
<form>
<label for="f1">Field 1</label><input id="f1">
<label for="f2">Field 2</label><input id="f2">
<label for="f3">Field 3</label><input id="f3">
<label for="f4">Field 4</label><input id="f4">
<label for="f5">Field 5</label><input id="f5">
<label for="f6">Field 6</label><input id="f6">
<label for="f7">Field 7</label><input id="f7">
<label for="f8">Field 8</label><input id="f8">
<label for="f9">Field 9</label><input id="f9">
<label for="f10">Field 10</label><input id="f10">
<label for="f11">Field 11</label><input id="f11">
<label for="f12">Field 12</label><input id="f12">
<label for="f13">Field 13</label><input id="f13">
<label for="f14">Field 14</label><input id="f14">
<label for="f15">Field 15</label><input id="f15">
<label for="f16">Field 16</label><input id="f16">
<label for="f17">Field 17</label><input id="f17">
<label for="f18">Field 18</label><input id="f18">
<label for="f19">Field 19</label><input id="f19">
<label for="f20">Field 20</label><input id="f20">
<label for="f21">Field 21</label><input id="f21">
<label for="f22">Field 22</label><input id="f22">
<label for="f23">Field 23</label><input id="f23">
<label for="f24">Field 24</label><input id="f24">
<label for="f25">Field 25</label><input id="f25">
<label for="f26">Field 26</label><input id="f26">
<label for="f27">Field 27</label><input id="f27">
<label for="f28">Field 28</label><input id="f28">
<label for="f29">Field 29</label><input id="f29">
<label for="f30">Field 30</label><input id="f30">
</form>
<script>
if (location.search.includes('fixed')) document.documentElement.classList.add('fixed');
const field = (n) => document.getElementById(`f${n}`);
const report = (how) => {
const box = document.activeElement.getBoundingClientRect();
const hidden = Math.max(0, Math.min(box.bottom, 64) - box.top);
const state = hidden >= box.height ? 'entirely hidden under the header' : hidden > 0 ? `${hidden}px of ${box.height}px hidden under the header` : 'fully visible below the header';
console.log(`${how}: ${document.activeElement.id} has focus, top at ${Math.round(box.top)}px, ${state}`);
};
// Scroll until field 12 sits 8px from the top of the window, under the header, and put focus on field 13.
scrollTo(0, scrollY + field(12).getBoundingClientRect().top - 8);
field(13).focus({ preventScroll: true });
// Shift+Tab (pressed by the reader) moves focus back to field 12.
addEventListener('focusin', () => requestAnimationFrame(() => setTimeout(() => {
report('Shift+Tab');
// The same field again, this time focused by a script.
field(13).focus({ preventScroll: true });
scrollTo(0, scrollY + field(12).getBoundingClientRect().top - 8);
field(12).focus();
requestAnimationFrame(() => setTimeout(() => report('focus()'), 0));
}, 0)), { once: true });
</script>
</body>
</html> | Without scroll padding | With scroll-padding-block-start: 72px on html |
|
|---|---|---|
| Shift+Tab | field 12 at 8 px, entirely hidden under the header | field 12 scrolled to 473 px (Firefox 474 px), fully visible |
focus() |
the same: entirely hidden | the same: fully visible |
scroll-padding tells the browser which part of the viewport counts when it scrolls something into view, so an
element under the header now counts as out of view and is scrolled into the clear area. Both engines put it in the
middle of that area rather than just below the header. The same property on the bottom edge,
scroll-padding-block-end, keeps a sticky footer or a cookie banner from covering focused elements, which is the
technique W3C documents as C43. Size the padding from the header’s real height, plus a little room: here 64 px and
8 px to spare.
Motion people can turn down
Movement on screen that the reader did not ask for can cause dizziness and nausea for people with vestibular
disorders. Operating systems let them ask for less motion, and prefers-reduced-motion: reduce matches when they
have. The safe default is to add movement only inside @media (prefers-reduced-motion: no-preference), so that a
browser that does not support the query, or a reader who asked for less, gets the calm version:
/* Movement only for people who have not asked their system for less of it. */
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth; }
.card { transition: translate 200ms ease-out; }
.card:hover { translate: 0 -4px; }
.spinner { animation: spin 0.8s linear infinite; }
}
/* For people who have: no travel, no spin. A short fade still says that something changed. */
@media (prefers-reduced-motion: reduce) {
.toast { animation: fade-in 150ms ease-out; }
}
@keyframes spin { to { rotate: 1turn; } }
@keyframes fade-in { from { opacity: 0; } } Reduced motion does not have to mean no change at all: a short fade still tells the reader that a message arrived, without travel across the screen. And whatever the preference, 2.2.2 asks that movement which starts by itself and lasts more than five seconds, such as a carousel, can be paused, stopped or hidden.
Contrast and colour
WCAG works out contrast from the relative luminance of the two colours, and the ratio runs from 1:1 (the same colour) to 21:1 (black on white). The sign-up page audited below had four colour problems; the program works out each pair before and after the fix:
// Contrast ratios of the sign-up page's colours, before and after the fix, by the formula WCAG 2.2 defines: the
// relative luminance of each colour, then (lighter + 0.05) / (darker + 0.05).
const luminance = (hex) => {
const channel = (i) => {
const c = parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(0) + 0.7152 * channel(1) + 0.0722 * channel(2);
};
const ratio = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Text needs 4.5:1 (3:1 when large); a focus indicator and a control's border need 3:1 against what is next to them.
// A link inside text needs 3:1 against the text, or a cue that is not colour, such as an underline.
const checks = [
{ what: 'hint text on white', need: 4.5, before: ['#a3a3a3', '#ffffff'], after: ['#595959', '#ffffff'] },
{ what: 'button label on the button', need: 4.5, before: ['#ffffff', '#5b9bff'], after: ['#ffffff', '#1d4ed8'] },
{ what: 'input border on white', need: 3, before: ['#cccccc', '#ffffff'], after: ['#767676', '#ffffff'] },
{ what: 'focus ring on white', need: 3, before: null, after: ['#1d4ed8', '#ffffff'] },
{ what: 'link against body text', need: 3, before: ['#333333', '#333333'], after: ['#1d4ed8', '#333333'], cueAfter: 'underlined' },
];
const state = (pair, need, cue) => {
if (!pair) return 'none drawn: fails';
const r = ratio(...pair);
const ok = r >= need || Boolean(cue);
return `${r.toFixed(2)}:1${cue ? `, ${cue}` : ''}: ${ok ? 'passes' : 'fails'}`;
};
for (const c of checks) {
console.log(`${c.what} (needs ${c.need}:1)`);
console.log(` before ${state(c.before, c.need)}`);
console.log(` after ${state(c.after, c.need, c.cueAfter)}`);
} Output
hint text on white (needs 4.5:1) before 2.52:1: fails after 7.00:1: passes button label on the button (needs 4.5:1) before 2.77:1: fails after 6.70:1: passes input border on white (needs 3:1) before 1.61:1: fails after 4.54:1: passes focus ring on white (needs 3:1) before none drawn: fails after 6.70:1: passes link against body text (needs 3:1) before 1.00:1: fails after 1.89:1, underlined: passes
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node contrast.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
axe-core reported the same 2.52:1 for the hint and 2.77:1 for the button. It did not report the input border at 1.61:1: its contrast rule checks text, and none of its rules tests the 3:1 that 1.4.11 asks of a control’s border. The link shows the other half of the rule: blue against dark grey text is only 1.89:1, so the fix keeps the underline, a cue that does not depend on colour at all.
Color Contrast Checker (WCAG 2.2) Check any pair of colours against the WCAG 2.2 ratios, and get the nearest colour that passes.Reflow, zoom and text spacing
At 400 % zoom on a 1,280 px wide window, a page is laid out 320 CSS pixels wide, which is why 1.4.10 asks for no sideways scrolling at that width: fixed widths, wide tables and long words are the usual causes, as the debugging lesson showed. Text spacing is the less obvious test. Under 1.4.12, nothing may be lost when a reader’s own stylesheet raises the spacing to the test values, each a multiple of the font size: 1.5 for the line height, 2 after each paragraph, 0.12 between letters and 0.16 between words. A card with a fixed height fails it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A card under the WCAG text spacing test</title>
<style>
body { margin: 0; padding: 16px; font: 16px/24px Arial, sans-serif; }
.card { inline-size: 280px; padding: 12px; background: #e8eefc; }
.card p { margin: 0 0 8px; }
/* A fixed height clips the text when a reader needs more space between lines and words. */
#before { block-size: 128px; overflow: hidden; }
/* The fix: a minimum height, so the card grows with its text. */
#after { min-block-size: 128px; }
/* What success criterion 1.4.12 asks a page to survive (often applied with a bookmarklet or an extension). */
.spacing * { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; }
.spacing p { margin-block-end: 2em !important; }
</style>
</head>
<body>
<div class="card" id="before"><p>Delivery in 2 to 4 days.</p><p>Free returns within 30 days of delivery, in the original box.</p></div>
<div class="card" id="after"><p>Delivery in 2 to 4 days.</p><p>Free returns within 30 days of delivery, in the original box.</p></div>
<script>
const report = (label) => {
for (const id of ['before', 'after']) {
const card = document.getElementById(id);
const cut = card.scrollHeight - card.clientHeight;
console.log(`${label} · ${id}: ${card.clientHeight}px tall, ${cut > 0 ? `${cut}px of text cut off` : 'all text visible'}`);
}
};
report('normal spacing');
document.body.classList.add('spacing');
report('WCAG text spacing');
</script>
</body>
</html> At normal spacing both cards were 152 px tall with every word visible. Under the test spacing, the card with
block-size: 128px and overflow: hidden lost 32 px of text, the whole last line, in Chromium and Firefox alike,
while the card with min-block-size: 128px grew to 184 px. Give text boxes minimum sizes, not fixed ones.
Hiding content, and who still gets it
“Hidden” means different things to sighted readers and to assistive technology, and each technique hides content from a different group. This page uses each common technique once, and Chromium’s accessibility tree, the structure screen readers read, says who still gets it:
hiding.css
/* Hidden from everyone: no box, and nothing for assistive technology. */
.gone { display: none; }
.invisible { visibility: hidden; }
/* Hidden from the eyes only: a 1px box clipped to nothing, still read out. */
.visually-hidden {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Two look-alikes that are no substitute: see the table in the lesson. */
.transparent { opacity: 0; }
.off-screen { position: absolute; inset-inline-start: -10000px; }
/* An icon drawn with generated content, and the same icon with empty alternative text. */
.next::after { content: " \2192"; }
.next-quiet::after { content: " \2192" / ""; } hiding.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ways to hide content, and who still gets it</title>
<link rel="stylesheet" href="hiding.css">
<style>body { margin: 0; font: 16px/24px Arial, sans-serif; }</style>
</head>
<body>
<p id="plain">Shown to everyone</p>
<p id="gone" class="gone">display: none</p>
<p id="invisible" class="invisible">visibility: hidden</p>
<p id="vh" class="visually-hidden">The visually hidden pattern</p>
<p id="transparent" class="transparent">opacity: 0</p>
<p id="off-screen" class="off-screen">Moved off screen</p>
<p id="aria" aria-hidden="true">Shown, but aria-hidden="true"</p>
<button id="delete" type="button"><span aria-hidden="true">🗑</span><span class="visually-hidden">Delete address</span></button>
<button id="next" type="button" class="next">Next</button>
<button id="next-quiet" type="button" class="next-quiet">Next</button>
<p><a id="hidden-link" class="transparent" href="#plain">A link nobody can see</a></p>
<script>
// What a sighted reader gets: is anything drawn? (The accessibility tree is read with the browser's tools.)
for (const el of document.querySelectorAll('[id]')) {
const box = el.getBoundingClientRect();
const style = getComputedStyle(el);
const drawn = box.width > 1 && box.height > 1 && style.visibility === 'visible' && style.opacity !== '0' && box.right > 0;
console.log(`${el.id}: ${drawn ? 'drawn' : 'not drawn'} (${Math.round(box.width)} × ${Math.round(box.height)} px)`);
}
</script>
</body>
</html> | Technique | Drawn on screen | In Chromium’s accessibility tree |
|---|---|---|
display: none |
no | no (not rendered) |
visibility: hidden |
no | no (not visible) |
the visually hidden pattern, clip-path: inset(50%) on a 1 px box |
no | yes |
opacity: 0 |
no | yes, and a link stays focusable |
| moved off screen | no | yes |
aria-hidden="true" |
yes | no |
Who perceives an element, by the way it is hidden
Text description of the diagram
The diagram shows four boxes in a column.
- Drawn and announced: ordinary content, which everyone gets.
- Drawn, not announced: aria-hidden="true", for example on a decorative icon. Sighted readers see it; assistive technology skips it.
- Announced, not drawn: the visually hidden pattern, opacity: 0, or an element moved off screen. Screen readers read it out; nothing shows on screen.
- Neither drawn nor announced: display: none or visibility: hidden. Nobody gets it.
The visually hidden pattern is the one to use for text that only screen-reader users need, such as “Delete
address” on a button that shows only a bin icon: the button’s accessible name came out as “Delete address”. It clips
the element with clip-path; older versions of the pattern used clip, which the CSS Masking specification
deprecates. opacity: 0 is a trap: the link in the example was still in the tab order, so keyboard users can land on
something nobody can see. To hide decorative content from assistive technology, aria-hidden="true" is an HTML
attribute, not CSS.
Text added with ::before or ::after is part of what assistive technology reads. A “Next” button whose arrow comes
from content: " \2192" was named “Next →”; with alternative text, content: " \2192" / "", it was named “Next”.
Alternative text for generated content is Baseline newly available, so older browsers simply drop the whole
declaration; put words that matter in the HTML, not in content.
Audit: the automated checks, then the ones only a person can do
Automated checks are fast and catch real problems, but they can only test what has a rule. axe-core’s own documentation says it finds about 57 % of WCAG issues on average. This sign-up page has seven problems; the corrected stylesheet fixes all of them:
signup/signup.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Create your account</title>
<link rel="stylesheet" href="signup.css" id="styles">
<script>
// signup.html?fixed loads the corrected stylesheet instead.
if (location.search.includes('fixed')) document.getElementById('styles').href = 'signup-fixed.css';
</script>
</head>
<body>
<header class="bar">Shop <button class="icon" type="button" aria-label="Help">?</button><button class="icon" type="button" aria-label="Close">×</button></header>
<main class="panel">
<h1>Create your account</h1>
<form>
<label for="email">Email</label>
<input id="email" type="email" autocomplete="email">
<label for="password">Password</label>
<input id="password" type="password" autocomplete="new-password" aria-describedby="hint">
<p class="hint" id="hint">At least 12 characters</p>
<p class="offer">Free delivery on your first order. Returns are free for 30 days after delivery, in the original box.</p>
<p><input id="terms" type="checkbox"> <label for="terms" class="inline">I agree to the <a href="#terms">terms of sale</a></label></p>
<button class="primary" type="submit">Create account <span class="spinner" aria-hidden="true"></span></button>
</form>
</main>
</body>
</html> signup/signup.css
/* The sign-up page as it was handed over: every rule below breaks something for someone. */
body { margin: 0; font: 16px/24px Arial, sans-serif; color: #333; }
.bar { position: sticky; inset-block-start: 0; display: flex; gap: 2px; align-items: center; block-size: 56px; padding-inline: 16px; background: #fff; border-block-end: 1px solid #ddd; }
.icon { inline-size: 18px; block-size: 18px; padding: 0; border: 0; background: #eee; font-size: 12px; line-height: 18px; }
.panel { inline-size: 480px; margin: 24px auto; }
label, input { display: block; }
input { box-sizing: border-box; inline-size: 100%; block-size: 40px; margin-block-end: 12px; border: 1px solid #ccc; }
:focus { outline: none; }
.hint { margin-block-start: 0; color: #a3a3a3; font-size: 14px; }
.offer { block-size: 40px; overflow: hidden; line-height: 20px; }
.inline { display: inline; }
a { color: #333; text-decoration: none; }
.primary { padding: 10px 20px; border: 0; color: #fff; background: #5b9bff; }
.spinner { display: inline-block; inline-size: 12px; block-size: 12px; border: 2px solid #fff; border-block-start-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { rotate: 1turn; } } signup/signup-fixed.css
/* The same page, corrected: contrast, visible focus, reflow, text spacing, targets and motion. */
body { margin: 0; font: 16px/24px Arial, sans-serif; color: #333; }
html { scroll-padding-block-start: 64px; }
.bar { position: sticky; inset-block-start: 0; display: flex; gap: 8px; align-items: center; block-size: 56px; padding-inline: 16px; background: #fff; border-block-end: 1px solid #ddd; }
.icon { min-inline-size: 24px; min-block-size: 24px; padding: 0; border: 0; background: #e5e5e5; font-size: 14px; }
.panel { max-inline-size: 480px; margin: 24px auto; padding-inline: 16px; }
label, input { display: block; }
input { box-sizing: border-box; inline-size: 100%; min-block-size: 40px; margin-block-end: 12px; border: 1px solid #767676; }
:focus-visible { outline: 3px solid #1d4ed8; outline-offset: 2px; }
.hint { margin-block-start: 0; color: #595959; font-size: 14px; }
.offer { min-block-size: 40px; line-height: 20px; }
.inline { display: inline; }
a { color: #1d4ed8; text-decoration: underline; }
.primary { padding: 10px 20px; border: 0; color: #fff; background: #1d4ed8; }
.spinner { display: inline-block; inline-size: 12px; block-size: 12px; border: 2px solid #fff; border-block-start-color: transparent; border-radius: 50%; }
@media (prefers-reduced-motion: no-preference) {
.spinner { animation: spin 0.8s linear infinite; }
}
@keyframes spin { to { rotate: 1turn; } } axe-core 4.13.0, run with its WCAG 2.2 A and AA rules at 320 px, gave the same result in both engines:
| Problem | axe-core 4.13.0 | Before, and after the fix |
|---|---|---|
| Hint and button text contrast | found: color-contrast, 2 elements |
2.52:1 and 2.77:1, then 7.00:1 and 6.70:1 |
| Help and close buttons of 18 × 18 px, 2 px apart | found: target-size, 2 elements |
18 × 18 px, then at least 24 × 24 px, 8 px apart |
| A link told apart only by colour | flagged for review: link-in-text-block |
the same colour as the text, then underlined |
| Focus outline removed | not found | focus drawn with nothing, then a 3 px outline |
| A 480 px wide form at 320 px | not found | the page 164 px wider than the window, then fitting it |
A 40 px box of text with overflow: hidden |
not found | 8 px cut off under the test spacing, then growing with its text |
| An endless spinner under reduced motion | not found | still spinning, then animation: none |
After the fix axe-core reported no violations, but the four rows it never saw needed four manual checks. The ones to run on every page, in this order:
- Keyboard. Tab through the whole page: every control reachable, the focus always visible and never under a sticky layer, and nothing invisible taking focus.
- Zoom and reflow. Narrow the window to 320 CSS px, or zoom to 400 %: no sideways scrolling except in tables, maps and code that need two dimensions.
- Text spacing. Apply the 1.4.12 values with a bookmarklet or a user stylesheet: no text cut off or overlapping.
- Forced colours and reduced motion. Emulate both in DevTools: every control and its focus still visible, and no travel or spin left over.
Practise: who perceives it, and is focus hidden?
Check yourself
7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What does the visually hidden pattern do, and why not use display: none or opacity: 0 instead?
It hides text from the screen while leaving it for assistive technology, for example a label for an icon-only
button. The usual rules give the element a 1 px box, clip it with clip-path: inset(50%), hide its overflow and
take it out of the flow with position: absolute, so it is drawn as nothing but stays in the accessibility tree.
display: none and visibility: hidden remove it from the accessibility tree too, so a screen reader never hears
it. opacity: 0 keeps it in the tree, but it still takes up space, and a link or a button with zero opacity stays
in the tab order, so keyboard users can focus something they cannot see. Older versions used the clip property,
which is deprecated in favour of clip-path.
Key takeaways
- Style focus with
:focus-visibleand anoutline; a transparent outline under a box-shadow ring keeps focus visible in forced colours mode, where shadows compute tonone. scroll-padding-block-startand-endkeep focused elements out from under sticky headers, footers and banners (2.4.11).- Add motion only inside
prefers-reduced-motion: no-preference; endless motion needs a way to stop it. - Text needs 4.5:1, large text 3:1; controls, their states and focus rings need 3:1; links inside text need an underline or 3:1 against the text.
- Give text boxes minimum heights, not fixed ones, and check reflow at 320 px and the 1.4.12 spacing values.
- Visually hidden text uses a clipped 1 px box;
display: noneandvisibility: hiddenhide it from everyone, andopacity: 0hides nothing from the keyboard. - An automated audit finds part of the problems; keyboard, zoom, text spacing, forced colours and reduced motion are checked by hand.
References
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Understanding WCAG 2.2: Non-text Contrast (W3C Web Accessibility Initiative)
- Understanding WCAG 2.2: Focus Not Obscured (Minimum) (W3C Web Accessibility Initiative)
- Technique C43: Using CSS scroll-padding to un-obscure content (W3C Web Accessibility Initiative)
- Understanding WCAG 2.2: Reflow (W3C Web Accessibility Initiative)
- Understanding WCAG 2.2: Text Spacing (W3C Web Accessibility Initiative)
- Technique G183: a contrast ratio of at least 3:1 to distinguish inline text links (W3C Web Accessibility Initiative)
- CSS Color Adjustment Module Level 1: forced colors mode (W3C CSS Working Group)
- CSS Scroll Snap Module Level 1: scroll-padding (W3C CSS Working Group)
- CSS Masking Module Level 1: the deprecated clip property (W3C CSS Working Group)
- CSS Generated Content Module Level 3: alternative text for content (W3C CSS Working Group)
- Accessible Name and Description Computation 1.2: name from content (W3C)
- prefers-reduced-motion (MDN Web Docs) (Mozilla)
- forced-colors (MDN Web Docs) (Mozilla)
- clip (MDN Web Docs) (Mozilla)
- axe-core (Deque Systems)
- Web features explorer: alt text for generated content (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress