Section 508 Patterns: text version

This is the complete written content of the Section 508 Patterns reference in plain HTML, with no script and no stylesheet: 16 patterns and a 49-item pre-launch checklist. It is generated at build time from the same data the interactive site uses, so it is always current.

What it does not have is the live demos: the interactive components you can operate with a keyboard or screen reader, and the Accessible / Broken switch that lets you experience each failure. Those need JavaScript. If your browser has scripting turned off, or you are using a text-only browser, you have two options: read on here, or open the interactive version in a browser with JavaScript enabled. Any current Firefox, Chrome, Edge or Safari works, including with a screen reader; if you are in a locked-down environment, this page is the fallback and it is not second-rate: every criterion, mapping and test step is here.

Visible focus indicator

The problem: Someone navigating with a keyboard has exactly one piece of information about where they are on the page: the focus ring. Deleting it without a replacement does not make the site cleaner; it makes it unusable, in the same way that hiding the mouse cursor would.

WCAG success criteria

Section 508

E205.4 incorporates WCAG 2.0 Level A and AA, which includes SC 2.4.7 Focus Visible. Note the version boundary honestly: SC 1.4.11 Non-text Contrast is a WCAG 2.1 addition and is therefore NOT incorporated by the 2017 Revised 508 Standards, though it is required by WCAG 2.1 AA and by the U.S. Department of Justice ADA Title II rule (2024), which adopts WCAG 2.1 AA for state and local government web content. WCAG 2.2 adds 2.4.11 Focus Not Obscured (Minimum) at AA and 2.4.13 Focus Appearance at AAA; neither is a 508 requirement.

How to test with a keyboard

  1. Click just above the demo, then press Tab five times to walk through the controls.
  2. In the accessible version every stop shows a thick ring offset from the control.
  3. Now click a button with the mouse: no ring appears, because :focus-visible knows the difference.
  4. In the broken version, press Tab five times and then press Enter. Something happened; you had no way to predict what.

What a screen reader should announce

What the broken version does

A scoped stylesheet sets outline: none on :focus and :focus-visible for everything in the demo. Focus still moves; you simply cannot see where it is.

Source of the working demo

/* The good version: one global rule, not per-component. */
:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;   /* keeps the ring off the control's own border */
  border-radius: 2px;
}

/* The ONLY safe way to write "remove the outline": remove it for
   pointer focus, where :focus-visible has already decided the user
   does not need it. Never a blanket `outline: none`. */
:focus:not(:focus-visible) {
  outline: none;
}

/* If the design truly cannot accept an outline, replace it with an
   indicator of equivalent visibility: a box-shadow ring, a border
   swap, an inverted background. What is NOT acceptable is nothing.
   The replacement needs 3:1 contrast against the adjacent colour
   (SC 1.4.11 Non-text Contrast, AA). */
.card:focus-visible {
  outline: none;                         /* replaced, not removed */
  box-shadow: 0 0 0 3px var(--focus);
}

/* Windows High Contrast Mode strips box-shadow. Keep a transparent
   outline so forced-colors mode paints a real one. */
@media (forced-colors: active) {
  :focus-visible { outline: 3px solid CanvasText; }
}

Back to top

Focus trap in a modal dialog

The problem: A modal that does not manage focus is a lie: it looks blocking but is not. Keyboard and screen-reader users Tab straight past it into the page underneath, filling in a form they cannot see, and when the dialog closes their focus is dumped at the top of the document with no memory of where they were.

WCAG success criteria

Section 508

E205.4 incorporates WCAG 2.0 A and AA, covering all four criteria above. Chapter 3 Functional Performance Criteria 302.1 (Without Vision) is the one this pattern speaks to most directly: without the role, the name, and the focus move, a blind user has no way to know a modal opened at all. Note that the Revised Standards also apply to software user interfaces via 502 and 503; the same dialog rules apply in a desktop or mobile app, not just on the web.

How to test with a keyboard

  1. Tab to "Delete this project…" and press Enter.
  2. Focus should already be inside the dialog, on the Delete button.
  3. Press Tab three or four times. Focus must cycle Delete → Cancel → Delete and never reach "Background button".
  4. Press Shift+Tab from Delete. It should wrap backwards to Cancel.
  5. Press Escape. The dialog closes and focus returns to "Delete this project…", not to the top of the page.
  6. Now switch to Broken and repeat: after two Tab presses you are in the background form, invisibly.

What a screen reader should announce

What the broken version does

The dialog is a plain div: no role, no aria-modal, no label, focus never moves into it, Escape does nothing, Tab walks into the background form, and closing drops focus onto the body.

Source of the working demo

function useFocusTrap({ active, onEscape, returnFocusTo }) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!active) return;
    const container = containerRef.current;
    if (!container) return;

    // 1. Remember where focus came from, so it can go back.
    const previouslyFocused =
      returnFocusTo?.current ?? document.activeElement;

    // 2. Move focus in.
    (getFocusable(container)[0] ?? container).focus();

    const onKeyDown = (e) => {
      // 3. Escape is the required keyboard exit. Without it this
      //    WOULD violate SC 2.1.2 No Keyboard Trap.
      if (e.key === 'Escape') { e.stopPropagation(); onEscape(); return; }
      if (e.key !== 'Tab') return;

      // 4. Cycle. Recompute each time: dialog contents change.
      const items = getFocusable(container);
      if (items.length === 0) { e.preventDefault(); container.focus(); return; }
      const first = items[0];
      const last  = items[items.length - 1];
      const inside = container.contains(document.activeElement);

      if (e.shiftKey ? (!inside || document.activeElement === first)
                     : (!inside || document.activeElement === last)) {
        e.preventDefault();
        (e.shiftKey ? last : first).focus();
      }
    };

    container.addEventListener('keydown', onKeyDown);
    return () => {
      container.removeEventListener('keydown', onKeyDown);
      // 5. Restore. isConnected guards against the trigger having
      //    been removed by the action the dialog performed.
      if (previouslyFocused?.isConnected) previouslyFocused.focus();
    };
  }, [active, returnFocusTo]);

  return containerRef;
}

// Markup
<div ref={dialogRef}
     role="dialog"
     aria-modal="true"
     aria-labelledby="dlg-title"
     aria-describedby="dlg-desc"
     tabindex="-1">
  <h2 id="dlg-title">Delete project?</h2>
  <p id="dlg-desc">This cannot be undone.</p>
  <button>Delete</button>
  <button onClick={close}>Cancel</button>
</div>

// In production also mark the rest of the page inert, which removes
// it from the tab order AND the accessibility tree:
//   <div id="app-root" inert={isModalOpen}>

Back to top

Keyboard-operable custom controls

The problem: A div with an onClick handler looks and behaves like a button for exactly one kind of user: someone with a working mouse and working eyes. It is not focusable, has no role, exposes no state, and does not respond to Space or Enter. This is the single most common way an otherwise well-built interface becomes unusable.

WCAG success criteria

Section 508

All four are WCAG 2.0 criteria and are incorporated by E205.4 for web content and by 502.3 (Accessibility Services) and 503 for software user interfaces. 502.3.1 through 502.3.14 spell out, in software terms, essentially what 4.1.2 requires: object role, state, name, and value must be programmatically determinable and, where the user can set them, settable. Functional Performance Criteria 302.7 (With Limited Manipulation) and 302.8 (With Limited Reach and Strength) apply directly; these are the users for whom the keyboard, a switch device, or voice is the only input method.

How to test with a keyboard

  1. Tab to the switch. In the accessible version it takes focus and shows a ring; in the broken version Tab skips it entirely.
  2. Press Space, then Enter. Both should toggle it, and the visible "On/Off" text should change.
  3. Tab to the "Actions" button and press Enter or Down Arrow to open the menu.
  4. Press Down and Up to move between items, Home and End to jump to the ends.
  5. Press Escape: the menu closes and focus returns to the Actions button.
  6. In the broken version, open the menu with the mouse and then press Tab: focus skips straight past all four items.

What a screen reader should announce

What the broken version does

The switch loses role, tabindex, and aria-checked; it becomes a decorated div with a click handler. The menu items lose role and tabindex, and the trigger loses aria-haspopup and aria-expanded. Everything still works perfectly with a mouse.

Source of the working demo

{/* ── FIRST, THE HONEST ANSWER ───────────────────────────────
    Do not build these from divs. A native control already has the
    role, the focusability, the keyboard model, and correct
    behaviour in Windows High Contrast Mode:

      <button type="button" aria-pressed={on}>Notifications</button>
      <input type="checkbox" role="switch" checked={on} … />

    Everything below is what you must re-implement by hand if you
    ignore that advice. ──────────────────────────────────────── */}

{/* Switch from a div */}
<div role="switch"
     tabindex="0"
     aria-checked={checked}
     aria-labelledby="notif-label"
     onClick={toggle}
     onKeyDown={(e) => {
       // Space AND Enter. Space must preventDefault or the page
       // scrolls under the user.
       if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle(); }
     }} />

{/* Menu button + menu (WAI-ARIA Authoring Practices) */}
<button aria-haspopup="menu" aria-expanded={open} onClick={toggleMenu}>
  Actions
</button>

<div role="menu" aria-label="Project actions" onKeyDown={onMenuKeyDown}>
  {items.map((item, i) => (
    <div role="menuitem"
         tabindex={i === activeIndex ? 0 : -1}
         ref={el => itemRefs.current[i] = el}>
      {item}
    </div>
  ))}
</div>

function onMenuKeyDown(e) {
  switch (e.key) {
    case 'ArrowDown': e.preventDefault(); next();  break;
    case 'ArrowUp':   e.preventDefault(); prev();  break;
    case 'Home':      e.preventDefault(); first(); break;
    case 'End':       e.preventDefault(); last();  break;
    case 'Escape':    e.preventDefault(); close(); trigger.focus(); break;
    case 'Tab':       close(); break;   // no preventDefault: let them leave
    case 'Enter':
    case ' ':         e.preventDefault(); activate(); close(); trigger.focus(); break;
  }
}

Back to top

Roving tabindex in a toolbar

The problem: Every button in a composite widget being its own tab stop turns a thirty-button editor toolbar into thirty keystrokes of obstacle between the user and the next thing on the page. Users of switch devices, who may press once every few seconds, feel this most acutely.

WCAG success criteria

Section 508

Incorporated through E205.4 (content) and 502/503 (software), which reference WCAG 2.0 Level A and AA. Roving tabindex is not named anywhere in the 508 text; it is a technique from the W3C WAI-ARIA Authoring Practices Guide, which is the reference implementation people mean when they say "the ARIA pattern". Functional Performance Criteria 302.7 With Limited Manipulation and 302.8 With Limited Reach and Strength are the ones that make the keystroke count a real accessibility issue and not just an ergonomics nicety.

How to test with a keyboard

  1. Tab from "Link before the toolbar". In the accessible version you land on exactly one toolbar button.
  2. Press Right Arrow several times. Focus moves along the toolbar and wraps around at the end.
  3. Press Home, then End, to jump to the first and last buttons.
  4. Press Tab. You leave the whole toolbar in one press and land on "Link after the toolbar".
  5. Press Shift+Tab back into the toolbar: focus returns to the button you last used, not to the first.
  6. In the broken version, Tab goes straight from the link before to the link after; the six controls do not exist for you.

What a screen reader should announce

What the broken version does

The container keeps role="toolbar" while the items become divs with role="button" and no tabindex. Nothing inside is focusable, and arrow keys are not handled; the announced role and the actual behaviour contradict each other.

Source of the working demo

const [activeIndex, setActiveIndex] = useState(0);
const refs = useRef([]);

// Move focus only in response to a key press, never on mount.
const shouldFocus = useRef(false);
useEffect(() => {
  if (shouldFocus.current) refs.current[activeIndex]?.focus();
}, [activeIndex]);

function onKeyDown(e) {
  const last = items.length - 1;
  const go = (i) => { shouldFocus.current = true; setActiveIndex(i); };
  switch (e.key) {
    // Wrapping is optional but conventional in a toolbar.
    case 'ArrowRight': e.preventDefault(); go(activeIndex === last ? 0 : activeIndex + 1); break;
    case 'ArrowLeft':  e.preventDefault(); go(activeIndex === 0 ? last : activeIndex - 1); break;
    case 'Home':       e.preventDefault(); go(0);    break;
    case 'End':        e.preventDefault(); go(last); break;
  }
}

<div role="toolbar" aria-label="Text formatting"
     aria-orientation="horizontal" onKeyDown={onKeyDown}>
  {items.map((item, i) => (
    <button ref={el => refs.current[i] = el}
            tabIndex={i === activeIndex ? 0 : -1}   // ← the whole trick
            aria-label={item.label}
            aria-pressed={isPressed(item)}
            onFocus={() => setActiveIndex(i)}>      // keep in sync on click
      <span aria-hidden="true">{item.glyph}</span>
    </button>
  ))}
</div>

/* Orientation and keys by role (WAI-ARIA Authoring Practices):
     toolbar   horizontal → ← →      vertical → ↑ ↓ (set aria-orientation)
     tablist   horizontal → ← →      + Home/End
     listbox   vertical   → ↑ ↓      + Home/End, type-ahead
     menu      vertical   → ↑ ↓      + Home/End, Esc, Tab closes
     grid      both       → all four + Ctrl+Home/End
   The alternative to real focus is aria-activedescendant: focus
   stays on the container and one attribute points at the active
   child. Fewer moving parts, but weaker AT support; prefer real
   focus unless you have a reason. */

Back to top

Accessible names for icon-only controls

The problem: A toolbar of pictograms is fast and compact for a sighted mouse user and completely opaque to everyone else. Without a name, a screen reader can only say "button", four times in a row, and a speech-input user has nothing to say out loud to activate it.

WCAG success criteria

Section 508

SC 4.1.2 and 1.1.1 are WCAG 2.0 criteria, incorporated by E205.4 for content and by 502/503 for software interfaces. Chapter 3 applies too: 302.1 Without Vision (nothing to announce) and 302.9 With Limited Language, Cognitive, and Learning Abilities (a picture with no word is ambiguous for far more people than screen-reader users). SC 2.5.3 Label in Name is a WCAG 2.1 addition and so is not part of the 2017 Revised 508 Standards, though it is required by WCAG 2.1 AA.

How to test with a keyboard

  1. Tab through the four toolbar buttons. They are reachable in both variants; this failure is invisible to a keyboard-only test.
  2. That is the lesson: keyboard operability and accessible naming are different problems, and passing one tells you nothing about the other.
  3. Compare the "Computed accessible names" list between the two variants.

What a screen reader should announce

What the broken version does

The first four buttons contain only an aria-hidden icon, with no aria-label and no visually hidden text, so they have no accessible name at all. The computed-names panel below shows exactly what is left for assistive technology to work with.

Source of the working demo

{/* Option 1: aria-label on the control, aria-hidden on the icon. */}
<button type="button" aria-label="Delete paragraph">
  <span aria-hidden="true">🗑</span>
</button>

{/* Option 2: visually hidden real text. Slightly more robust:
    it survives machine translation of the page, and it appears in
    the DOM where a developer will actually notice it. */}
<button type="button">
  <svg aria-hidden="true" focusable="false" width="16" height="16">…</svg>
  <span class="sr-only">Delete paragraph</span>
</button>

{/* Option 3: an SVG that IS the content, named by <title>. */}
<svg role="img" aria-labelledby="trash-title" width="16" height="16">
  <title id="trash-title">Delete paragraph</title>
  <path d="…" />
</svg>

{/* WRONG: title attribute only. Not reliably announced, invisible to
    touch users, and only appears after a hover delay. */}
<button title="Delete">🗑</button>

{/* WRONG: nothing at all. Announced as "button", or worse, as the
    Unicode name of the emoji: "wastebasket, button". */}
<button>🗑</button>

/* The .sr-only class this depends on. Note it is CLIPPED, not
   display:none; display:none removes it from the accessibility
   tree, which defeats the entire purpose. */
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

Back to top

Live regions for async status

The problem: Something finished, failed, or changed count, and the only evidence is a piece of text that appeared somewhere the user is not looking. Sighted users catch it peripherally. A screen-reader user, whose attention is wherever their cursor is, is told nothing at all.

WCAG success criteria

Section 508

This is the clearest example on the site of a version boundary that matters. SC 4.1.3 Status Messages is NEW in WCAG 2.1, so it is not incorporated by the 2017 Revised Section 508 Standards, which reference WCAG 2.0 Level A and AA via E205.4. Claiming "508 requires aria-live" would be wrong. What is true: 4.1.3 is required by WCAG 2.1 AA, by EN 301 549, and by the U.S. DOJ ADA Title II rule (2024) for state and local government. Under 508 alone, the closest binding hooks are Chapter 3 Functional Performance Criteria 302.1 Without Vision and 302.2 With Limited Vision; an unannounced status message means the information simply is not available to those users.

How to test with a keyboard

  1. Activate "Save (polite status)" with Enter and leave focus on the button.
  2. The text below changes to "Saving…" and then "Saved. 3 records updated.", with no focus movement at all. That is the point: focus stays put.
  3. Activate the upload button and note the alert appears the same way.

What a screen reader should announce

What the broken version does

Both regions lose their role and aria-live attributes, and they are only mounted once there is a message, so even a screen reader that polls would have nothing to subscribe to. The text is visible and completely silent.

Source of the working demo

{/* Render the region on FIRST paint and keep it mounted.
    Assistive tech watches existing live regions for mutations; a
    region that appears at the same instant as its text is routinely
    missed. This is the number-one live-region bug. */}
<div role="status" aria-live="polite" aria-atomic="true">
  {statusText}
</div>

<div role="alert" aria-live="assertive">
  {errorText}
</div>

/* Which to use
   ─────────────────────────────────────────────────────────────
   role="status"  ≡ aria-live="polite"     queued, non-interrupting
   role="alert"   ≡ aria-live="assertive"  interrupts immediately
   role="log"     ≡ polite + relevant="additions"  chat, console
   role="timer"   ≡ off by default; announce explicitly instead

   aria-atomic="true"  read the WHOLE region on any change
   aria-atomic="false" read only what changed (default)
   aria-relevant       which mutations count (additions text, default)
   aria-busy="true"    suppress announcements while a batch renders
*/

// If the update is a direct response to the user's own action on
// the control they are focused on, prefer MOVING FOCUS to the new
// content over announcing it. Live regions are for things that
// happen without the user asking right now.

// React StrictMode / fast re-render caveat: setting the same string
// twice does not re-announce. If a repeat announcement matters
// (e.g. "still saving…"), vary the text or clear then set on a tick.

Back to top

Form labels, instructions, and errors

The problem: Forms are where accessibility failures become expensive, because a form is usually the point of the whole page. An unlabelled field is announced as "edit, blank"; a validation error shown only as a red border is invisible; and a failed submit that leaves focus on the button gives a screen-reader user no idea anything went wrong.

WCAG success criteria

Section 508

The 2.0-era criteria here (1.3.1, 3.3.1, 3.3.2, 3.3.3, 1.4.1, 4.1.2, 2.4.6) are all incorporated by E205.4 for content and 502/503 for software. SC 1.3.5 Identify Input Purpose is a WCAG 2.1 addition and is therefore NOT part of the 2017 Revised 508 Standards; it is included here because it is required by WCAG 2.1 AA and is genuinely useful. Functional Performance Criteria that bear on forms: 302.1 Without Vision, 302.3 Without Perception of Color (the required-field indicator), and 302.9 With Limited Language, Cognitive, and Learning Abilities (error messages that explain the fix rather than restating that something is wrong).

How to test with a keyboard

  1. Tab through the form. Each field should announce its label; in the broken version they announce nothing useful.
  2. Click the visible "Email address" text. In the accessible version this focuses the input (that is what a real <label> does); in the broken version nothing happens.
  3. Leave everything empty and press Enter on Submit.
  4. Focus should jump to the error summary at the top of the form.
  5. Tab to the first error link and press Enter: focus moves to the field that needs fixing.
  6. Type an email without an @ and submit again. The message should tell you what would be valid.

What a screen reader should announce

What the broken version does

Labels become placeholders or unassociated spans, error messages are visually adjacent but not linked by aria-describedby, aria-invalid is never set, required is shown by a red asterisk only, and the error summary neither announces nor takes focus.

Source of the working demo

{/* 1. Label association: <label for> ↔ id. Nothing else. */}
<label for="email">
  Email address <span class="hint">(required)</span>
</label>

{/* 2. Instructions BEFORE the input, wired with aria-describedby
      so they are announced as part of the field. */}
<span class="hint" id="email-hint">
  We will only use this to reply. Format: name@example.com
</span>

<input id="email"
       type="email"
       required
       autocomplete="email"              {/* SC 1.3.5 (AA, WCAG 2.1) */}
       aria-invalid={hasError || undefined}
       {/* Keep the hint AND add the error. Space-separated id list,
           in the order you want them read. */}
       aria-describedby={hasError ? "email-hint email-error" : "email-hint"} />

{hasError && (
  <span class="field-error" id="email-error">
    <span aria-hidden="true">✕ </span>
    Enter an email address in the form name@example.com; it must include an @.
  </span>
)}

{/* 3. Error summary that TAKES FOCUS on submit failure. */}
<div ref={summaryRef} tabindex="-1" role="alert"
     aria-labelledby="summary-heading">
  <h2 id="summary-heading">There are 2 problems with this form</h2>
  <ul>
    <li><a href="#email" onClick={focusField}>Enter an email address.</a></li>
  </ul>
</div>

function onSubmit(e) {
  e.preventDefault();
  const errs = validate(values);
  setErrors(errs);
  if (Object.keys(errs).length) {
    // After paint, so the summary exists to receive focus.
    setTimeout(() => summaryRef.current.focus(), 0);
  }
}

/* Required indication that is not colour-only:
     ✅ the word "(required)" in the label, plus the required attribute
     ✅ "All fields are required unless marked optional"
     ❌ a red asterisk and nothing else
   If you must use an asterisk, keep the required attribute AND
   explain the convention in text before the first field. */

/* Anti-patterns, all of which look fine to a sighted mouse user:
     ❌ placeholder as the only label
     ❌ a <span> styled to look like a label, with no for=
     ❌ aria-label that differs from the visible label (breaks 2.5.3)
     ❌ "Invalid input" with no explanation of what would be valid
     ❌ errors announced only by turning the border red
     ❌ title="…" as the label, hover-only, and unreliable */

Back to top

Headings and landmarks

The problem: Screen-reader users do not read pages top to bottom; they skim by heading and by landmark, the same way a sighted reader skims by looking. A page of unlabelled divs with font-size headings removes both. Surveys of screen-reader users have consistently put headings at the top of the list of how they find things on a page.

WCAG success criteria

Section 508

E205.4 incorporates WCAG 2.0 A and AA, which covers 1.3.1, 2.4.1 and 2.4.6. Note the honest boundary: SC 2.4.10 Section Headings is Level AAA, so neither WCAG AA nor Section 508 requires you to add headings that are not already there; they require that structure you do present visually is also present programmatically. Chapter 3 Functional Performance Criterion 302.1 Without Vision is the practical driver, and 302.9 (Limited Language, Cognitive, and Learning Abilities) benefits too, since a clear outline helps anyone who finds long prose hard going.

How to test with a keyboard

  1. Headings and landmarks have no keyboard behaviour of their own; this is a structure problem a keyboard test cannot find.
  2. The nearest keyboard proxy: press Tab from the top of the page and see whether a skip link offers to bypass the navigation.
  3. In Firefox or Chrome devtools, open the Accessibility tree and look at the top-level structure.

What a screen reader should announce

What the broken version does

The mini-page is entirely divs with font-size standing in for heading levels, plus one real h4 followed by an h6 so the outline visibly skips a level. The scanner reports what is left: no landmarks, a broken outline.

Source of the working demo

<body>
  <a class="skip-link" href="#main">Skip to main content</a>

  {/* <header> at the top level → role="banner". Inside an <article>
      it is NOT a landmark, which trips people up constantly. */}
  <header>
    <h1>Section 508 Patterns</h1>   {/* exactly one h1 per page */}
  </header>

  {/* Multiple navigations MUST be distinguished by name, or the
      landmarks list reads "navigation, navigation, navigation". */}
  <nav aria-label="Site and pattern index"> … </nav>

  <main id="main" tabindex="-1">
    <h2>Patterns</h2>              {/* h1 → h2, no skipping */}
    <article aria-labelledby="skip-link-title">
      <h3 id="skip-link-title">Skip link</h3>
      <h4>Live demo</h4>           {/* h3 → h4 */}
    </article>
  </main>

  <aside aria-label="Related reading"> … </aside>   {/* complementary */}
  <footer> … </footer>                              {/* contentinfo */}
</body>

/* Implicit landmark roles: use the ELEMENT, not the role attribute,
   because the element also carries the browser behaviour:

     <header>   banner         (top level only)
     <nav>      navigation
     <main>     main           (one per page)
     <aside>    complementary  (top level only)
     <footer>   contentinfo    (top level only)
     <form>     form           ONLY when it has an accessible name
     <section>  region         ONLY when it has an accessible name
     <search>   search         (or role="search" for older browsers)

   Rules that actually matter:
     • One <main>, one <h1>, per page.
     • Never skip a heading level going down (h2 → h4 is a failure of
       the outline, though WCAG does not name the skip itself).
     • Name every landmark you have more than one of.
     • Do NOT put "Navigation" in aria-label on a <nav>; the role is
       already announced. aria-label="Primary navigation" produces
       "Primary navigation navigation". Just "Primary".
     • Every visible piece of the page should be inside SOME landmark;
       content outside them is unreachable by landmark navigation. */

Back to top

Data tables with real headers

The problem: A table without header semantics is a grid of numbers with the meaning stripped out. A sighted user reconstructs "which row, which column" from position in a fraction of a second; a screen-reader user, hearing one cell at a time, gets "1,022" and nothing else.

WCAG success criteria

Section 508

Incorporated by E205.4 via WCAG 2.0 A and AA. Worth knowing the history: the ORIGINAL 1998 Section 508 standards had explicit table provisions, §1194.22(g) "Row and column headers shall be identified for data tables" and (h) for multi-level headers. Those provisions were REPLACED by the 2017 refresh, which references WCAG instead. If you see a requirements document citing 1194.22(g), it is quoting the superseded standard. Functional Performance Criterion 302.1 Without Vision is what makes this concrete.

How to test with a keyboard

  1. Tables are not interactive, so there is nothing to Tab to, which is precisely why a keyboard-only test finds nothing wrong here.
  2. If the table scrolls horizontally, its scroll container must be focusable so a keyboard user can reach the right-hand columns. Tab to the table region and press the arrow keys.

What a screen reader should announce

What the broken version does

The data table uses <td> for every cell with bold styling standing in for header semantics, and has no caption. A second example shows a layout table, which announces a table structure that means nothing.

Source of the working demo

<table>
  {/* The caption IS the table's accessible name, and it appears in
      the screen reader's list-of-tables dialog. A heading above the
      table is not associated with it. */}
  <caption>Units shipped by region, first three quarters</caption>

  <thead>
    <tr>
      <th scope="col">Region</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      {/* Row headers matter as much as column headers, and are the
          ones people forget. */}
      <th scope="row">North</th>
      <td>1,204</td><td>1,338</td><td>1,411</td>
    </tr>
  </tbody>
</table>

{/* Irregular tables (merged cells, two header rows) need explicit
    pairing. Prefer splitting into simple tables instead; this is
    fragile and almost never maintained correctly. */}
<th id="h-q1-units">Units</th>
<td headers="h-region-north h-q1 h-q1-units">1,204</td>

{/* A table used for layout, if you cannot remove it, must have its
    semantics suppressed so it is not announced as a data table: */}
<table role="presentation"> … </table>

{/* Responsive tables: do NOT set display:block / display:flex on
    table elements to make them stack; that DESTROYS the table
    semantics in the accessibility tree. Wrap it in a scroll
    container instead, and make the container focusable so a
    keyboard user can scroll it: */}
<div class="table-scroll" tabindex="0" role="region"
     aria-label="Units shipped by region">
  <table> … </table>
</div>

Back to top

Colour contrast, and colour as the only signal

The problem: Low contrast is the most-reported accessibility failure on the web, year after year, and it is entirely mechanical to detect. Separately and just as importantly: roughly 1 in 12 men has some form of colour vision deficiency, so red-versus-green as the only difference between "passed" and "failed" is information that simply does not arrive.

WCAG success criteria

Section 508

SC 1.4.3 and 1.4.1 are WCAG 2.0 Level A/AA criteria and are incorporated by E205.4. Two things worth being precise about: SC 1.4.11 Non-text Contrast is a WCAG 2.1 addition and so is not a 2017 Revised 508 requirement, and SC 1.4.6 is Level AAA and is not required by either. Chapter 3 Functional Performance Criteria 302.2 (With Limited Vision) and 302.3 (Without Perception of Color) are the 508 provisions that speak to this most directly, and 302.3 is unusually explicit for a functional criterion; it requires a mode of operation that does not require user perception of colour.

How to test with a keyboard

  1. Tab into the colour fields and type a hex value. The ratio and every verdict update as you type.
  2. Tab to Swap and press Enter: the ratio is unchanged, because contrast is symmetric.
  3. Enter an unparseable value like "blueish": the field is marked aria-invalid and an error explains what is accepted.

What a screen reader should announce

What the broken version does

The verdict table is replaced by a list of coloured squares. The information is present on screen and encoded purely in hue: nothing to read, nothing to announce, and indistinguishable to a visitor with red–green colour blindness.

Source of the working demo

/** Linearise one 8-bit sRGB channel.
 *  The 0.03928 branch is normative: this is NOT a plain gamma 2.2. */
function linearise(channel8Bit) {
  const c = channel8Bit / 255;
  return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}

/** WCAG relative luminance. Coefficients are Rec. 709 primaries. */
function relativeLuminance({ r, g, b }) {
  return 0.2126 * linearise(r)
       + 0.7152 * linearise(g)
       + 0.0722 * linearise(b);
}

/** Contrast ratio. The +0.05 models ambient flare, which is why the
 *  maximum is 21:1 (black on white) and not infinity. */
function contrastRatio(fg, bg) {
  const l1 = relativeLuminance(fg);
  const l2 = relativeLuminance(bg);
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}

/* Thresholds
   ───────────────────────────────────────────────────────────────
   SC 1.4.3  Contrast (Minimum)   AA   4.5:1 normal, 3:1 large
   SC 1.4.6  Contrast (Enhanced)  AAA  7:1   normal, 4.5:1 large
   SC 1.4.11 Non-text Contrast    AA   3:1   UI components, graphics

   "Large" = 18pt (24px), or 14pt bold (18.66px bold).

   Exempt from 1.4.3: disabled controls, pure decoration, logotypes,
   and text that is part of a picture of significant other content.
   "Disabled" is an exemption people over-claim; a disabled control
   nobody can read is still a usability failure.

   Round DOWN when you display the ratio. 4.4996 shown as "4.50,
   passes" is a quiet lie. */

/* Anti-pattern this criterion does NOT cover, but 1.4.1 does:
   using colour as the ONLY way to convey something. Add a glyph,
   a word, a shape, an underline, anything that survives being
   printed in greyscale. */

Back to top

Images, icons, and alternative text

The problem: Alternative text is the oldest accessibility requirement on the web and still the most consistently botched, in both directions: information-carrying images with no alt at all, and decorative flourishes described in loving detail on every page.

WCAG success criteria

Section 508

SC 1.1.1 and 1.4.5 are WCAG 2.0 A/AA criteria, incorporated by E205.4. As with tables, there is history worth knowing: the ORIGINAL 1998 standard stated this directly at §1194.22(a), "A text equivalent for every non-text element shall be provided", and that provision was superseded by the 2017 refresh. Chapter 3 Functional Performance Criteria 302.1 Without Vision and 302.2 With Limited Vision apply. SC 1.4.11 is WCAG 2.1 and therefore outside the 508 reference.

How to test with a keyboard

  1. Images are not focusable, so a keyboard test finds nothing here; as with tables, that is the point worth internalising.
  2. One keyboard-adjacent check: an inline SVG inside a link or button must not become its own tab stop. focusable="false" prevents that in legacy engines.

What a screen reader should announce

What the broken version does

The informative chart loses its alt attribute entirely, the decorative flourish gains a detailed description of itself, and the decorative inline SVG loses aria-hidden and focusable="false".

Source of the working demo

{/* INFORMATIVE: the alt carries the information, not a
    description of the picture. */}
<img src="q4-shipments.svg"
     alt="Units shipped per quarter: Q1 400, Q2 580, Q3 760, Q4 900,
     a steady rise across the year." />

{/* COMPLEX: when one sentence is not enough, put the real data
    next to it and point at it. */}
<img src="revenue.svg" alt="Revenue by region, 2024. Full data in
                            the table below." />
<table> … </table>

{/* DECORATIVE: alt is PRESENT and EMPTY. Not missing. */}
<img src="flourish.svg" alt="" />

{/* FUNCTIONAL: the image is inside a link or button, so the alt
    describes the ACTION, not the picture. */}
<a href="/"><img src="logo.svg" alt="Home"></a>
{/* not alt="Company logo"; the user cannot "click a logo" */}

{/* INLINE SVG, decorative */}
<svg aria-hidden="true" focusable="false" viewBox="0 0 16 16">…</svg>

{/* INLINE SVG, meaningful */}
<svg role="img" aria-labelledby="warn-title" viewBox="0 0 16 16">
  <title id="warn-title">Warning: 3 unresolved issues</title>
  …
</svg>

{/* TEXT IN IMAGES: SC 1.4.5 Images of Text (AA) says use real
    text unless the presentation is essential (logotypes are the
    main exemption). Real text also zooms, reflows, translates,
    and can be selected. */}

/* Decision tree
   ────────────────────────────────────────────────────────────
   Does the image convey information the surrounding text does not?
     no  → alt=""  (or a CSS background, which is better still)
     yes → is it inside a link or button?
             yes → alt describes the destination or action
             no  → alt conveys the information, as a sentence
   Is the information too complex for a sentence?
     yes → short alt + the full content in adjacent text/table */

Back to top

Reflow at 320px and text resize to 200%

The problem: A low-vision user browsing a desktop site at 400% zoom is effectively on a 320-pixel-wide viewport. If the layout has any fixed pixel width in it, they get a page that scrolls both ways, meaning every single line of text requires a horizontal scroll to read, then a scroll back. It is the difference between slow and impossible.

WCAG success criteria

Section 508

This pattern is a good example of why version matters. SC 1.4.4 Resize Text is WCAG 2.0 Level AA and IS incorporated by Section 508 via E205.4. SC 1.4.10 Reflow and SC 1.4.12 Text Spacing are both WCAG 2.1 additions and are therefore NOT part of the 2017 Revised 508 Standards; they are required by WCAG 2.1 AA, by EN 301 549, and by the DOJ ADA Title II rule (2024). If a procurement document asks for "508 compliance" and you build to WCAG 2.1 AA, you have exceeded the requirement, which is the correct direction to err. Functional Performance Criterion 302.2 With Limited Vision is the underlying 508 hook.

How to test with a keyboard

  1. Set the container width to 320px. In the accessible version everything stacks; in the broken version a horizontal scrollbar appears inside the frame.
  2. Set the text size to 200%. In the broken version the dashed box clips its own content and there is no way to recover it.
  3. Drag the bottom-right corner of the frame; it is a resizable element, so you can try intermediate widths.
  4. For the real test: press Ctrl and + (Cmd and + on macOS) five times to reach 400% browser zoom on a 1280px window, and check that no page-level horizontal scrollbar appears.

What a screen reader should announce

What the broken version does

The inner layout is pinned to 900px, the heading and paragraph use white-space: nowrap, one box has a fixed height with overflow hidden, and two columns have flex-shrink: 0 so they can never stack.

Source of the working demo

/* ── REFLOW (SC 1.4.10, AA, WCAG 2.1) ──────────────────────────
   Target: usable at 320 CSS px wide with no two-dimensional
   scrolling. 320px = 1280px at 400% zoom, which is how a low-vision
   desktop user actually gets there. */

/* 1. Never set a width in px on a layout container. */
.card { width: 100%; max-width: 60rem; }      /* not width: 960px */

/* 2. Let flex children shrink. flex-basis + wrap, and minWidth: 0
      because the default min-width:auto refuses to shrink below
      content size: the number-one cause of mystery overflow. */
.row { display: flex; flex-wrap: wrap; gap: 1rem; }
.col { flex: 1 1 18rem; min-width: 0; }

/* 3. Same for grid: minmax(0, 1fr), not 1fr. */
.shell { display: grid; grid-template-columns: minmax(0,1fr); }

/* 4. Long unbreakable strings (URLs, hashes, code) overflow
      everything. */
.prose { overflow-wrap: anywhere; }

/* 5. Wide things scroll INSIDE their own box, not the page. Make
      the box focusable so a keyboard user can scroll it. */
.table-scroll { overflow-x: auto; }
<div class="table-scroll" tabindex="0" role="region" aria-label="…">


/* ── RESIZE TEXT (SC 1.4.4, AA, WCAG 2.0, a real 508 requirement) ──
   Target: 200% text size with no loss of content or functionality. */

/* 6. min-height, never height, on anything containing text. */
.badge { min-height: 2rem; }                  /* not height: 2rem */

/* 7. Never overflow: hidden on a text container "to keep it tidy".
      Tidy at 100% is deleted at 200%. */

/* 8. Do not pin the root font size; that overrides the visitor's
      browser preference outright. */
html { font-size: 16px; }   /* ❌ */
html { }                    /* ✅ inherit the browser default */

/* 9. Never disable pinch-zoom. This is a real, shipped, common
      failure of 1.4.4 on mobile. */
<meta name="viewport" content="width=device-width, initial-scale=1">
{/* ❌ maximum-scale=1, ❌ user-scalable=no */}


/* ── TEXT SPACING (SC 1.4.12, AA, WCAG 2.1) ────────────────────
   The user may override, and nothing may be lost:
     line-height   1.5 × font size
     paragraph gap 2   × font size
     letter-spacing 0.12em
     word-spacing   0.16em
   Test it by pasting that as a user stylesheet. If your layout
   already uses min-height and wrapping, it will pass unchanged. */

Back to top

Respecting reduced motion

The problem: Large or repetitive movement can cause nausea, dizziness, and migraine in people with vestibular disorders, and it makes content hard to read for anyone with an attention or reading difficulty. The operating system already knows who those people are; the site just has to ask.

WCAG success criteria

Section 508

Be precise here, because this is a pattern people over-claim. Section 508 incorporates WCAG 2.0 Level A and AA via E205.4, so SC 2.2.2 Pause, Stop, Hide and SC 2.3.1 Three Flashes are 508 requirements. SC 2.3.3 Animation from Interactions, the criterion actually about prefers-reduced-motion, is WCAG 2.1 AND Level AAA, so it is required by neither Section 508 nor WCAG AA. Separately, the Revised 508 Standards do have a directly relevant hardware/software provision: 503.4 requires user controls for captions and audio description, and Chapter 3 Functional Performance Criterion 302.9 covers limited cognitive and learning abilities. Honour the media query because it helps real people, and cite 2.2.2 when you need a requirement to point at.

How to test with a keyboard

  1. Tab to "Pause animation" and press Enter. The motion should stop and the button label should change.
  2. Tab to "Simulate reduce motion" and press Space. The square should snap to its resting position with no movement.
  3. Turn the setting on for real in your OS and reload: the animation should never start.
  4. In the broken version, look for the pause control. There is not one.

What a screen reader should announce

What the broken version does

The animation ignores prefers-reduced-motion entirely, runs infinitely at speed with a large translate-rotate-scale, and offers no way to pause it, which also fails SC 2.2.2 at Level A.

Source of the working demo

/* Define the animation, then neutralise it. Note that the element
   still ENDS UP in the right state; it just gets there instantly.
   Removing the animation must not remove the outcome. */
@keyframes slide-in {
  from { transform: translateX(-2rem); opacity: 0; }
  to   { transform: translateX(0);     opacity: 1; }
}

.panel { animation: slide-in 300ms ease-out; }

@media (prefers-reduced-motion: reduce) {
  .panel { animation: none; }          /* final state, no journey */
}

/* A global backstop for everything you forgot. 0.01ms rather than 0
   so transitionend / animationend listeners still fire and nothing
   hangs waiting for an event that never arrives. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* Read it in JavaScript for things CSS cannot reach: canvas,
   WebGL, an autoplaying video, a physics-based scroll library. */
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
const reduced = mql.matches;
mql.addEventListener('change', (e) => setReduced(e.matches));

/* What "reduce" should actually mean, in order of preference:
     1. Remove the movement, keep the outcome.
     2. Cross-fade instead of translating or zooming.
     3. Shorten it drastically.
   What it must NOT mean: removing the feature.

   The worst offenders are parallax, auto-playing carousels, and
   large-area zoom or spin transitions, motion that fills the
   viewport is far more provocative than a small one.

   Also required regardless of the media query, at Level A:
     SC 2.2.2 Pause, Stop, Hide, anything that moves automatically,
       lasts more than 5 seconds, and sits alongside other content
       needs a pause/stop/hide control.
     SC 2.3.1 Three Flashes or Below Threshold, nothing may flash
       more than three times per second. This one is a seizure risk,
       not a comfort preference. */

Back to top

Session timeouts that warn and extend

The problem: A silent session timeout punishes exactly the people who take longest: someone using a screen reader, a switch device, or voice input, someone re-reading a complicated question, someone who had to step away. They come back to an empty form and no explanation.

WCAG success criteria

Section 508

SC 2.2.1 Timing Adjustable is WCAG 2.0 Level A and is incorporated by E205.4, so a warn-and-extend flow is a genuine Section 508 requirement, one of the more commonly missed ones in agency applications. SC 4.1.3 and SC 2.2.6 are both WCAG 2.1 additions (and 2.2.6 is AAA on top of that), so neither is a 508 requirement. Functional Performance Criteria 302.7 With Limited Manipulation and 302.9 With Limited Language, Cognitive, and Learning Abilities are the ones that explain why the extra time matters: they describe the users for whom every interaction simply takes longer.

How to test with a keyboard

  1. Press Enter on "Start the session timer" and wait about 18 seconds without touching anything.
  2. In the accessible version a warning dialog appears and focus moves into it automatically.
  3. Press Escape: that counts as "keep working" and resets the clock.
  4. Start it again and let it run out. In the accessible version your draft survives; in the broken version the textarea is emptied without warning.
  5. Check that "Keep working" is reachable and operable without a mouse; a warning you cannot dismiss from the keyboard is no warning at all.

What a screen reader should announce

What the broken version does

No warning is shown at all. The session simply expires and the textarea is cleared, destroying unsaved work with no announcement, no dialog, and no way to extend.

Source of the working demo

/* SC 2.2.1 Timing Adjustable (Level A) is satisfied if ANY ONE of
   these is true:
     • TURN OFF: the user can switch the limit off before meeting it
     • ADJUST: the user can extend it to at least 10× the default
     • EXTEND: the user is warned before it expires, told simply
                    how to extend (e.g. "press the space bar"), and
                    can extend at least 10 times
     • REAL-TIME: the limit is essential to a real-time event
                    (an auction, a live exam)
     • ESSENTIAL: extending would invalidate the activity
     • 20 HOURS: the limit is longer than 20 hours

   Session security limits are NOT automatically "essential". The
   standard accessible answer is the warn-and-extend pattern below. */

const WARN_BEFORE_MS = 2 * 60 * 1000;   // warn 2 minutes out

useEffect(() => {
  const warn = setTimeout(() => setShowWarning(true),
                          SESSION_MS - WARN_BEFORE_MS);
  const end  = setTimeout(signOut, SESSION_MS);
  return () => { clearTimeout(warn); clearTimeout(end); };
}, [sessionStartedAt]);

{showWarning && (
  <div role="alertdialog"           /* dialog + assertive announcement */
       aria-modal="true"
       aria-labelledby="to-title"
       aria-describedby="to-desc"
       ref={trapRef}                /* focus trap; Esc = keep working */
       tabindex="-1">
    <h2 id="to-title">Your session is about to end</h2>
    <p id="to-desc">
      You will be signed out in 2 minutes and your draft will be lost.
    </p>
    <button onClick={extendSession}>Keep working</button>
    <button onClick={signOut}>Sign out now</button>
  </div>
)}

{/* The countdown itself must NOT be a live region. Announcing every
    second makes the page unusable with a screen reader. role="timer"
    is silent by default: announce only at the thresholds. */}
<p role="timer" aria-live="off">Session ends in {mm}:{ss}</p>

/* Two more rules that cost nothing:
     • PRESERVE THE DATA. Draft to localStorage or the server. SC 2.2.5
       Re-authenticating (AAA) asks for exactly this, and it is the
       difference between an annoyance and a lost afternoon.
     • WARN EARLY ENOUGH to be actionable. A screen-reader user needs
       to hear the announcement, find the dialog, and read the options;
       20 seconds is not enough.

/* Criterion numbering, stated correctly:
     SC 2.2.1 Timing Adjustable        A    ← the timeout criterion
     SC 2.2.3 No Timing                AAA  no time limits at all
     SC 2.2.5 Re-authenticating        AAA  resume without data loss
     SC 2.2.6 Timeouts                 AAA  warn about data-loss limits
     SC 1.4.13 Content on Hover/Focus  AA   tooltips and popovers,
                                            NOT about session limits,
                                            despite being frequently
                                            cited that way. */

Back to top

Speech input and Label in Name

The problem: Voice control activates a control by matching what you say against its accessible name. When a developer adds a "more descriptive" aria-label that replaces the visible text, the screen-reader announcement improves and the button becomes impossible to click by voice: an accessibility fix that breaks accessibility.

WCAG success criteria

Section 508

Be careful here. SC 2.5.3 Label in Name is a WCAG 2.1 addition and is therefore NOT incorporated by the 2017 Revised Section 508 Standards, which reference WCAG 2.0. What Section 508 does provide is Chapter 3 Functional Performance Criteria: 302.7 With Limited Manipulation and 302.8 With Limited Reach and Strength describe the users who rely on speech input, and 302.6 Without Speech covers the mirror case: an interface must not REQUIRE speech. So under 508 alone the argument is a functional-performance one; under WCAG 2.1 AA, EN 301 549, and the DOJ ADA Title II rule (2024), 2.5.3 applies directly.

How to test with a keyboard

  1. Read the "Visible label" and "Accessible name" columns side by side. Where the visible string does not appear inside the name, voice control cannot reach that control.
  2. If you have Windows Voice Access or macOS Voice Control, turn it on and try saying "click Save" against each variant. This is the only way to really feel it.
  3. Free proxy without any voice software: in browser devtools, open the Accessibility pane, select a button, and compare the Name field to the text you can see.

What a screen reader should announce

What the broken version does

Each button carries an aria-label that replaces rather than extends its visible text, so the visible string does not appear in the accessible name at all. The check table below computes and reports the mismatch.

Source of the working demo

{/* ✅ Best: no aria-label. The visible text IS the accessible
    name, so they cannot diverge. */}
<button>Previous step</button>

{/* ✅ Fine: the name STARTS WITH the visible text and adds context
    for screen-reader users. "click Save" still works. */}
<button aria-label="Save the application form">Save</button>

{/* ❌ Broken: the name REPLACES the visible text. A voice-control
    user saying "click Save" gets nothing; the word "Save" does not
    appear in the accessible name at all. */}
<button aria-label="Submit the application form">Save</button>

{/* ❌ Also broken, and very common: an icon-and-text button where
    someone labelled it for the icon. */}
<button aria-label="Navigate backwards">← Previous step</button>

{/* The same trap in forms: the placeholder is visible, the
    aria-label is the name, and they disagree: */}
<input aria-label="Electronic mail address" placeholder="Email" />
{/* ✅ instead: a real <label> whose text is the name */}
<label for="email">Email</label><input id="email">

/* SC 2.5.3 Label in Name (Level A, WCAG 2.1)
   "For user interface components with labels that include text or
    images of text, the name contains the text that is presented
    visually."

   Practical reading:
     • CONTAINS, not equals; you may add to it.
     • Word order matters. Extra words at the FRONT are the usual
       failure ("Search products" as the name for a button reading
       "Search" is fine; "Product search" is not, because the
       visible string "Search" is present but the leading words
       break the match for most voice engines).
     • Punctuation and case differences are tolerated.
     • It applies to anything with a visible text label: buttons,
       links, form fields, tabs, menu items.

   Who this affects: voice-control users (Dragon, Windows Voice
   Access, macOS and iOS Voice Control, Android Voice Access), and
   also screen-reader users who can see the screen, hearing a name
   that does not match what they are looking at is disorienting. */

Back to top

Pre-launch checklist

49 checkable items, organised by success criterion. A practical working list, not a conformance audit and not legal advice.

1.1.1 Non-text Content (Level A, WCAG 2.0)

1.3.1 Info and Relationships (Level A, WCAG 2.0)

1.3.2 Meaningful Sequence (Level A, WCAG 2.0)

1.3.5 Identify Input Purpose (Level AA, WCAG 2.1)

1.4.1 Use of Color (Level A, WCAG 2.0)

1.4.3 Contrast (Minimum) (Level AA, WCAG 2.0)

1.4.4 Resize Text (Level AA, WCAG 2.0)

1.4.5 Images of Text (Level AA, WCAG 2.0)

1.4.10 Reflow (Level AA, WCAG 2.1)

1.4.11 Non-text Contrast (Level AA, WCAG 2.1)

1.4.12 Text Spacing (Level AA, WCAG 2.1)

1.4.13 Content on Hover or Focus (Level AA, WCAG 2.1)

2.1.1 Keyboard (Level A, WCAG 2.0)

2.1.2 No Keyboard Trap (Level A, WCAG 2.0)

2.2.1 Timing Adjustable (Level A, WCAG 2.0)

2.2.2 Pause, Stop, Hide (Level A, WCAG 2.0)

2.3.1 Three Flashes or Below Threshold (Level A, WCAG 2.0)

2.4.1 Bypass Blocks (Level A, WCAG 2.0)

2.4.2 Page Titled (Level A, WCAG 2.0)

2.4.3 Focus Order (Level A, WCAG 2.0)

2.4.4 Link Purpose (In Context) (Level A, WCAG 2.0)

2.4.5 Multiple Ways (Level AA, WCAG 2.0)

2.4.6 Headings and Labels (Level AA, WCAG 2.0)

2.4.7 Focus Visible (Level AA, WCAG 2.0)

2.5.3 Label in Name (Level A, WCAG 2.1)

2.5.4 Motion Actuation (Level A, WCAG 2.1)

3.1.1 Language of Page (Level A, WCAG 2.0)

3.2.1 On Focus (Level A, WCAG 2.0)

3.2.2 On Input (Level A, WCAG 2.0)

3.3.1 Error Identification (Level A, WCAG 2.0)

3.3.2 Labels or Instructions (Level A, WCAG 2.0)

3.3.3 Error Suggestion (Level AA, WCAG 2.0)

3.3.4 Error Prevention (Legal, Financial, Data) (Level AA, WCAG 2.0)

4.1.2 Name, Role, Value (Level A, WCAG 2.0)

4.1.3 Status Messages (Level AA, WCAG 2.1)

Back to top