Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

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:

Three focus rings, and a page that reports how each is drawn

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():

A sticky header and the field that has focus HTML · focus-under-header.html
<!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 those who have not asked for less CSS · motion.css
/* 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:

The sign-up page's colours, before and after JavaScript · contrast/contrast.mjs
// 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

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:

A card under the text spacing test HTML · text-spacing.html
<!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:

Ways to hide content

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">&#128465;</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
Four outcomes of hiding: drawn and announced, drawn only, announced only, and neither, each with the CSS or HTML that causes it.Drawn and announcedordinary contentDrawn, not announcedaria-hidden="true" on a decorative iconAnnounced, not drawnthe visually hidden pattern · opacity: 0 · moved off screenNeither drawn nor announceddisplay: none · visibility: hiddenhide from assistive technologyor hide from the eyesor hide from both

Who perceives an element, by the way it is hidden

Text description of the diagram

The diagram shows four boxes in a column.

  1. Drawn and announced: ordinary content, which everyone gets.
  2. Drawn, not announced: aria-hidden="true", for example on a decorative icon. Sighted readers see it; assistive technology skips it.
  3. Announced, not drawn: the visually hidden pattern, opacity: 0, or an element moved off screen. Screen readers read it out; nothing shows on screen.
  4. 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:

A sign-up page and its corrected stylesheet

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:

  1. Keyboard. Tab through the whole page: every control reachable, the focus always visible and never under a sticky layer, and nothing invisible taking focus.
  2. 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.
  3. Text spacing. Apply the 1.4.12 values with a bookmarklet or a user stylesheet: no text cut off or overlapping.
  4. Forced colours and reduced motion. Emulate both in DevTools: every control and its focus still visible, and no travel or spin left over.
Color Blindness Simulator See how a palette or a screenshot looks with common colour vision deficiencies.

Practise: who perceives it, and is focus hidden?

Exercise · Medium · JavaScript

Check who perceives an element, and whether focus is hidden

Two questions come up in every accessibility review of CSS: who can perceive this element, and can a keyboard user see the element that has focus? Write three functions in checks.mjs and export them.

1. exposure(el) returns 'everyone', 'eyes only', 'screen readers only' or 'nobody'. el describes an element's computed state; every field is optional: display (default 'block'), visibility ('visible'), opacity (1), ariaHidden (false), width and height in px (100 and 20), clipPath ('none') and offScreen (false, true when positioned outside the page). - The element is rendered when display is not 'none' and visibility is 'visible'. - It is drawn when it is rendered, its opacity is above 0, it is more than 1 px wide and more than 1 px tall, its clipPath is not 'inset(50%)' and it is not off screen. - It is announced by assistive technology when it is rendered and ariaHidden is false. - Drawn and announced is 'everyone', drawn only is 'eyes only', announced only is 'screen readers only', and neither is 'nobody'. 2. obscured(focus, bands) takes the top and bottom of the focused element, { top, bottom } in px from the top of the viewport, and the author's sticky or fixed bands that span the viewport's width, such as a header or a cookie banner, each { top, bottom }. Return 'hidden' when the bands together cover all of the element (this fails success criterion 2.4.11), 'partly hidden' when they cover some of it (it fails only 2.4.12), and 'visible' when they cover none. Count an area covered by two bands once. 3. scrollPadding(bands, viewportHeight) returns { start, end }, the scroll padding in px that keeps scrolled-to content clear of the bands. start is how far down the bands that touch the top of the viewport (top of 0 or less) reach: the largest of their bottom values. end is how far up the bands that touch the bottom (bottom of viewportHeight or more) reach, measured from the bottom edge. Either is 0 when no band touches that edge.

The sample tests import the functions from checks.mjs and run in your browser.

Starter code · checks.mjs

/** Who perceives an element: 'everyone', 'eyes only', 'screen readers only' or 'nobody'. */
export function exposure(el) {
  // Replace this line with your code.
  return 'everyone';
}

/** How much of a focused element the sticky bands hide: 'visible', 'partly hidden' or 'hidden'. */
export function obscured(focus, bands) {
  // Replace this line with your code.
  return 'visible';
}

/** The scroll padding that keeps scrolled-to content clear of bands stuck to the top and bottom edges. */
export function scrollPadding(bands, viewportHeight) {
  // Replace this line with your code.
  return { start: 0, end: 0 };
}
The sample tests · checks.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { exposure, obscured, scrollPadding } from './checks.mjs';

test('hidden from everyone', () => {
  assert.equal(exposure({ display: 'none' }), 'nobody');
  assert.equal(exposure({ visibility: 'hidden' }), 'nobody');
  assert.equal(exposure({ visibility: 'collapse' }), 'nobody');
  assert.equal(exposure({ display: 'none', ariaHidden: true }), 'nobody');
});

test('hidden from the eyes only', () => {
  assert.equal(exposure({ width: 1, height: 1, clipPath: 'inset(50%)' }), 'screen readers only');
  assert.equal(exposure({ opacity: 0 }), 'screen readers only');
  assert.equal(exposure({ offScreen: true }), 'screen readers only');
  assert.equal(exposure({ width: 1, height: 1 }), 'screen readers only');
});

test('hidden from assistive technology only', () => {
  assert.equal(exposure({ ariaHidden: true }), 'eyes only');
  assert.equal(exposure({ ariaHidden: true, opacity: 0 }), 'nobody');
});

test('shown to everyone', () => {
  assert.equal(exposure({}), 'everyone');
  assert.equal(exposure({ opacity: 0.4, width: 24, height: 24 }), 'everyone');
});

test('a sticky header over the focused field', () => {
  const header = { top: 0, bottom: 64 };
  assert.equal(obscured({ top: 8, bottom: 48 }, [header]), 'hidden');
  assert.equal(obscured({ top: 40, bottom: 80 }, [header]), 'partly hidden');
  assert.equal(obscured({ top: 64, bottom: 104 }, [header]), 'visible');
  assert.equal(obscured({ top: 473, bottom: 513 }, [header]), 'visible');
});

test('two bands that together cover the field', () => {
  const bands = [{ top: 0, bottom: 64 }, { top: 60, bottom: 120 }];
  assert.equal(obscured({ top: 50, bottom: 110 }, bands), 'hidden');
  assert.equal(obscured({ top: 100, bottom: 140 }, bands), 'partly hidden');
  assert.equal(obscured({ top: 300, bottom: 340 }, []), 'visible');
});

test('scroll padding for bands at the top and the bottom', () => {
  assert.deepEqual(scrollPadding([{ top: 0, bottom: 64 }], 915), { start: 64, end: 0 });
  assert.deepEqual(scrollPadding([{ top: 0, bottom: 64 }, { top: 0, bottom: 104 }, { top: 835, bottom: 915 }], 915), { start: 104, end: 80 });
  assert.deepEqual(scrollPadding([{ top: 300, bottom: 360 }], 915), { start: 0, end: 0 });
});
A hint

For obscured, cut each band down to the part that overlaps the element: from the larger of the two tops to the smaller of the two bottoms (when that is negative, they do not meet). Sort those parts from the top and walk down them, adding only the length below what you have already counted.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

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.

  1. Question 1 of 7 A focus ring is drawn with box-shadow and outline none. What do keyboard users see in Windows contrast themes?

    Choose one answer.

    Show the answer to question 1

    Answer: Nothing, because box-shadow computes to none in forced colours mode

    Forced colours mode replaces the page's colours and computes box-shadow and text-shadow to none, so a ring made only of a shadow disappears. In the lesson's runs, an outline survived in a system colour, and so did a transparent outline placed under the shadow, which is the cheap way to keep both looks.

  2. Question 2 of 7 A form has a 64 px sticky header. When focus moves to a field that sits under the header, the field stays hidden. Which change fixes it for keyboard users?

    Choose one answer.

    Show the answer to question 2

    Answer: scroll-padding-block-start on the html element, a little larger than the header

    The browser scrolls a focused element into view only when it is outside the area that counts as visible. The scroll padding takes the header's strip out of that area, so the field is scrolled into the clear part: in the lesson's run it moved from 8 px, entirely hidden, to the middle of the window. Raising the fields over the header would hide the header instead, and fixed positioning has the same overlap.

  3. Question 3 of 7 Which of these leave the text available to a screen reader in Chromium's accessibility tree?

    Choose every answer that is right.

    Show the answer to question 3

    Answer:

    • opacity: 0
    • An element moved off screen with a large negative inset
    • The visually hidden pattern with clip-path inset(50%)

    Only display none and visibility hidden removed the text from the tree. The three others hide it from the eyes alone, which is what the visually hidden pattern is for; opacity 0 is the risky one, because a link or a button stays focusable while nobody can see it.

  4. Question 4 of 7 The lesson worked out the contrast of white button text on #5b9bff as 2.77:1. Normal-size text needs at least how much, as the first number of the ratio?

    Type a number.

    Show the answer to question 4

    Answer: 4.5

    Success criterion 1.4.3 asks for 4.5:1 for normal text and 3:1 for large text, which is at least 24 px, or about 18.7 px when bold. The fixed button, white on #1d4ed8, reached 6.70:1.

  5. Question 5 of 7 A card has block-size: 128px and overflow: hidden. Under the WCAG text spacing test its last line disappears. What is the fix?

    Choose one answer.

    Show the answer to question 5

    Answer: min-block-size: 128px instead of block-size, so the card grows with its text

    The test raises line height, paragraph spacing, letter spacing and word spacing; a fixed height cannot grow, so text is cut off: 32 px in the lesson's run. A minimum height keeps the design's size for short text and lets longer text push the card taller, to 184 px in the same run.

  6. Question 6 of 7 A button reads "Next" in the HTML and adds an arrow with .next::after { content: " \2192"; }. What accessible name did Chromium give it?

    Choose one answer.

    Show the answer to question 6

    Answer: Next →

    Text from ::before and ::after takes part in the accessible name. With alternative text, content " \2192" / "", the name was "Next". Alternative text for generated content is Baseline newly available, so words that matter belong in the HTML.

  7. Question 7 of 7 After the fix, axe-core reports no violations on the sign-up page. What does that tell you?

    Choose one answer.

    Show the answer to question 7

    Answer: That none of its rules failed; focus, reflow, text spacing and motion still need checks by hand

    axe-core's documentation says it finds about 57 % of WCAG issues on average. On the original page it found the contrast and target-size problems and flagged the link for review, but the removed focus outline, the 164 px of sideways scrolling at 320 px, the clipped text and the endless spinner were only found by the manual checks.

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-visible and an outline; a transparent outline under a box-shadow ring keeps focus visible in forced colours mode, where shadows compute to none.
  • scroll-padding-block-start and -end keep 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: none and visibility: hidden hide it from everyone, and opacity: 0 hides 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.