Frontend / Checklist
Accessibility rules, grouped by the part of the page they fix
Every rule carries a priority and a concrete fix - the attribute, role, property or technique that clears it - and sits in the group where you would go looking for it: keyboard, forms, ARIA, document structure, visuals, components, content, animation, media and interaction. The reference patterns live in the accessibility cheatsheet; this page is the audit list you work through before a launch.
80 rules in 10 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
Accessibility is the one frontend discipline with a legal deadline attached. The European Accessibility Act has applied to new consumer-facing digital products since June 2025, the ADA is enforced through private lawsuits in the US, and Section 508 governs anything sold to the federal government - and all of them measure against WCAG. The product cost is quieter: a checkout a screen reader cannot complete is a conversion you never see fail, and a form that blocks paste is a support ticket you never trace back to its cause.
The bar is WCAG 2.2 Level AA. The priority on each row is about ship risk, not spec level: Critical rows lock a whole class of users out or carry direct legal exposure, High rows are what an audit reports first, Medium rows are real defects that rarely block a task on their own, and Low rows are polish. Work top to bottom inside each group, and start with the launch checklist if you only have an afternoon.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Keyboard
If it cannot be done with Tab, Enter, Space, Escape and the arrow keys, it cannot be done by a large share of users - keyboard access is the base every other group assumes.
| Rule | Priority | What to do |
|---|---|---|
| Everything works with a keyboard alone | Critical | WCAG 2.1.1. Every link, button, field, menu and custom widget is reachable with Tab and operable with Enter, Space or the arrow keys. Use native elements first; a <div> with a click handler needs tabindex="0", a role and key handlers before it counts. |
| Move focus on purpose when the UI changes | High | After a route change, a dialog opening, an inline delete or a "load more", put focus somewhere useful with element.focus() - the new heading, the dialog, the next row. Never leave it on an element that no longer exists, which drops the user back to the top of the document. |
| Sticky UI never covers the focused element | High | WCAG 2.4.11 (Focus Not Obscured). Set scroll-padding-top on html to the header height, or scroll-margin-top on focusable elements, so a sticky header, cookie bar or bottom sheet cannot sit on top of the thing that has focus. |
| Focus order matches the reading order | High | WCAG 2.4.3. Keep the DOM in visual order and never reorder interactive content with CSS order, flex-direction: row-reverse or grid placement; the tab sequence follows the DOM, not the layout. |
| A skip link is the first tab stop | High | WCAG 2.4.1 (Bypass Blocks). The first focusable element in <body> is a link to the <main> landmark, visually hidden until it receives focus. Give the target tabindex="-1" so focus actually moves there in every browser rather than only scrolling. |
| No autofocus on page load | Low | Drop the autofocus attribute. It skips screen reader users past the page title and context, scrolls the viewport on mobile and pops a keyboard nobody asked for. A search page whose only content is one field is the single defensible exception. |
The whole skip-link pattern is two elements, and the tabindex="-1" on the target is the part most implementations forget:
<body>
<a class="skip-link" href="#page">Skip to main content</a>
<header>...</header>
<!-- tabindex="-1": focusable by the link, not by Tab -->
<main id="page" tabindex="-1">
...
</main>
</body> Gotcha: tabindex="0" makes an element reachable, not usable. A div with a click handler and a tabindex still ignores Enter and Space, announces as a generic group and has no disabled state - which is why the fix for a clickable div is <button>, not more attributes.
Forms
Forms are where an accessibility failure has a price you can measure, because every blocked field is an abandoned order or sign-up.
| Rule | Priority | What to do |
|---|---|---|
| Every control has an associated label | Critical | WCAG 1.3.1 and 3.3.2. Use <label for="..."> pointing at the field's id, or wrap the control in the label. Placeholder text is not a label: it vanishes on input, fails contrast and is skipped by some screen readers. |
| Image buttons carry alt text | Critical | <input type="image"> has no text content, so its alt attribute is the entire accessible name. Write the action ("Search", "Place order"), not a description of the picture. |
| Sign-in never depends on a cognitive test | High | WCAG 3.3.8 (Accessible Authentication). No memorised puzzles, transcription or object-recognition CAPTCHAs as the only route in. Allow password managers and paste, offer a magic link or passkey, and use a non-interactive challenge such as Cloudflare Turnstile when you need bot protection. |
| Never ask twice for what the user already entered | High | WCAG 3.3.7 (Redundant Entry). Within one process - checkout, sign-up, a multi-step form - auto-populate the fields you already know or offer a "same as shipping address" selection. Re-confirming a password for security is the one exemption. |
| One label per field | Medium | Two <label> elements pointing at the same id, or a label plus an aria-label, produce a name that is either concatenated or silently overridden. Pick one source of truth; move hints and format rules into aria-describedby. |
| The visible label text is in the accessible name | Medium | WCAG 2.5.3 (Label in Name). If the button says "Send message", the accessible name must contain "Send message" - an aria-label="Submit" breaks voice control, where users speak what they see. Prefer visible text and skip aria-label on labelled controls entirely. |
| Paste works in every input | Medium | Never intercept paste events or clear a field on paste, including password, confirm-password and one-time-code fields. Blocking paste defeats password managers and is itself a 3.3.8 failure. |
| Select elements are named | Medium | A <select> gets its name from <label for> like any other control. A placeholder-style first <option> ("Choose a country") is not a label, and neither is the heading above the control. |
A field that fails validation needs three things: a label, an error the field points at, and the invalid state itself:
<label for="card-number">Card number</label>
<input id="card-number" name="cardNumber" type="text"
inputmode="numeric" autocomplete="cc-number" required
aria-describedby="card-number-error" aria-invalid="true">
<p id="card-number-error">Enter the 16 digits on the front of the card, without spaces.</p> Gotcha: required is enough on its own - adding aria-required="true" alongside it makes some screen readers announce "required" twice. The same goes for disabled and aria-disabled: use the native attribute unless the control must stay focusable so the user can find out why it is off.
ARIA
Most of these rows are what axe-core checks in a second; the reason to list them is that a wrong ARIA attribute is worse than none, because it overrides what the browser already got right.
| Rule | Priority | What to do |
|---|---|---|
| Every input, button and interactive element has an accessible name | Critical | WCAG 4.1.2 (Name, Role, Value). The name comes, in priority order, from aria-labelledby, aria-label, a <label>, or the element's own text. Icon-only buttons, custom checkboxes and links that wrap only an image are the usual failures - add visible text, an aria-label, or alt on the image. |
| Never put aria-hidden on body | Critical | <body aria-hidden="true"> removes the entire page from the accessibility tree while everything stays focusable, so a screen reader user can Tab through controls that do not exist. Hide the background behind a modal with inert on the siblings, or use <dialog> with showModal(). |
| Announce dynamic changes with a live region | High | WCAG 4.1.3 (Status Messages). Render an empty role="status" (polite) or role="alert" (assertive) container on page load and update its text when results load, a save completes or an error appears. Never inject the region and the message in the same frame. |
| Use only ARIA attributes that exist | High | Every aria-* attribute must be in the WAI-ARIA specification. Typos such as aria-labeledby, aria-describeby or aria-role are ignored silently and the element loses the name or description you thought it had. axe reports these as aria-valid-attr. |
| Every referenced id is unique and present | High | aria-labelledby, aria-describedby, aria-controls, aria-owns and aria-activedescendant take id references. A missing target gives no name; a duplicated id gives the first match in DOM order, which is rarely the one you meant. Generate ids in components with useId() or its equivalent. |
| Roles get the parent and child roles they require | High | Some roles only work as a pair: listitem inside list, tab inside tablist, option inside listbox, row inside grid or table, menuitem inside menu. Breaking the pair makes assistive tech read a bare role with no context, or drop the widget entirely. |
| Dialogs, tooltips, meters, progress bars, toggles, tree items and command elements are named | Medium | Anything with role="dialog", alertdialog, tooltip, meter, progressbar, switch, treeitem, menuitem or link needs a name: aria-labelledby pointing at its visible heading or label is best, aria-label when nothing visible exists. A dialog named "Dialog" is unnamed. |
| Attribute values are valid for their type | Medium | aria-expanded and aria-selected take only true or false; aria-live takes off, polite or assertive; aria-current takes page, step, location, date, time, true or false. A value outside the list falls back to the default, so aria-expanded="yes" reports collapsed. |
| Use only real role values | Medium | role must be a role defined in WAI-ARIA: role="navigation", not role="nav"; role="button", not role="btn". An unknown role is ignored and the element keeps its native semantics, so the widget is announced as whatever it was before you touched it. |
| Only use attributes the role allows | Medium | Each role supports a specific set of states and properties. aria-checked on role="button", aria-selected on a link or aria-expanded on a <div> with no role does nothing except confuse the tools. Check the role's supported states in the ARIA spec before adding an attribute. |
| Include every attribute the role requires | Medium | role="checkbox" and role="radio" require aria-checked, role="combobox" requires aria-expanded, role="heading" requires aria-level, role="slider" and role="meter" require aria-valuenow. Without them the role is announced with no state, which is worse than no role. |
| Do not use deprecated roles or attributes | Medium | role="directory" was deprecated in ARIA 1.2 - use a plain list. aria-grabbed and aria-dropeffect have been deprecated since ARIA 1.1 and current screen readers ignore them; build drag and drop announcements with a live region instead. axe flags both under aria-deprecated-role. |
| Nothing focusable inside an aria-hidden container | Medium | aria-hidden="true" hides content from screen readers but leaves it in the tab order, so a hidden button becomes a ghost stop that announces nothing. Add inert to the container, set tabindex="-1" on the descendants, or hide it properly with the hidden attribute. |
| No focusable descendants inside role="text" | Medium | role="text" - a WebKit-only role that flattens its subtree into one string for VoiceOver - hides any link, button or input inside it from the accessibility tree while leaving it in the tab order. Move the interactive element outside the container, or drop the role. |
| Hide decorative elements from assistive technology | Medium | WCAG 1.1.1. Icons next to text, divider images and background flourishes get aria-hidden="true" on inline SVG and icon fonts, alt="" on a decorative <img>, and a CSS background for anything purely visual. Never hide something a sighted user needs to understand the page. |
Status messages need a container that already exists when the message arrives:
<!-- Rendered empty on page load -->
<div class="results-status" role="status" aria-live="polite"></div>
// Later, once the fetch resolves - only the text changes
document.querySelector('.results-status').textContent = '12 results found'; Gotcha: ARIA changes what assistive technology is told and nothing about what the browser does. role="button" on a div announces a button but adds no tab stop, no Enter or Space activation and no disabled state - all of that is still your JavaScript, which is why the native element wins every time it exists.
Document structure
Screen reader users navigate by headings, landmarks, lists and tables far more than by reading top to bottom, so the outline is the interface.
| Rule | Priority | What to do |
|---|---|---|
| Headings form a hierarchy with no skipped levels | Critical | WCAG 1.3.1 and 2.4.6. One <h1> for the page, <h2> for each major section, <h3> inside those. Never jump from h2 to h4 because the smaller size looked right - pick the level by outline position and style it with a class. Heading navigation is the most-used screen reader shortcut, and a broken outline breaks it. |
| The page still makes sense with CSS off | High | Disable styles and read the page top to bottom: the source order should be the reading order, content carried by ::before strings or background images should not exist, and nothing should depend on layout alone to separate a menu from the article. What survives without CSS is roughly what a screen reader gets. |
| Every id on the page is unique | High | WCAG 4.1.1 was retired in 2.2, but a duplicated id still breaks <label for>, aria-labelledby, aria-describedby and skip links because the browser takes the first match. Generate ids inside components with useId() or a similar helper, and fail the build on a duplicate. |
| Navigation regions are landmarks with distinct names | High | WCAG 1.3.1 and 2.4.1. Wrap each navigation block in <nav> and, when there is more than one, name each with aria-label="Main", aria-label="Footer" or aria-label="Breadcrumb". Two unnamed nav landmarks are announced identically and are useless to jump between. |
| Data tables use th header cells tied to their data cells | Medium | WCAG 1.3.1. Real <table> markup with <th scope="col"> in the header row and <th scope="row"> for row headers; for two levels of headers, give each th an id and reference it from the cell's headers attribute. A grid of divs gives a data cell no context at all. |
| Every data table has a unique accessible name | Medium | Add a <caption> as the first child - it is announced when focus enters the table and shows in the table list - or aria-labelledby pointing at the heading above. Two tables named "Results" on one page are indistinguishable. |
| Lists contain only list items | Medium | The direct children of <ul> and <ol> are <li> elements and nothing else - no div wrappers, no stray links, no span dividers - and every <li> sits inside a list. Anything else breaks the "list, 12 items" announcement and the item-by-item navigation that depends on it. |
| Related items go in ul, ol or dl | Medium | A nav menu, a set of cards, a feature list, a breadcrumb: each is a list, and marking it up as one tells the screen reader how many items there are before reading them. Use <ol> when the order carries meaning. Never fake a list with <br> or bullet characters in a paragraph. |
| Definition lists are dl with dt and dd pairs only | Medium | Term-and-description content - a glossary, key-value metadata, a FAQ - goes in <dl> with each <dt> followed by one or more <dd>. A <div> around each pair is allowed for styling; anything else inside the list, or dt and dd outside one, is invalid. |
| Headings are never empty | Medium | Every heading contains text a screen reader can announce. An icon-only heading, an empty one left behind by a template, or one whose only child is an image without alt shows up in the heading list as a blank entry. Remove the element or give it text. |
Gotcha: role="list" on a <ul> is not redundant in Safari. list-style: none strips the list semantics in VoiceOver, so a styled nav menu silently stops announcing as a list. Put the explicit role back when you remove the bullets.
Visual
Zoom, contrast, target size and orientation - the rules that decide whether low-vision and motor-impaired users can use the page at all, and most of them are measurable in DevTools.
| Rule | Priority | What to do |
|---|---|---|
| Content reflows at 400% zoom without horizontal scrolling | Critical | WCAG 1.4.10 (Reflow). At 320 CSS pixels wide - a 1280px screen at 400% - everything fits in one column with no two-directional scrolling. Use fluid widths, flex-wrap, min-width: 0 on flex children, and overflow-x: auto only on data tables and code blocks. |
| Text and UI meet the contrast ratios | High | WCAG 1.4.3 and 1.4.11. Body text needs 4.5:1 against its background, large text (24px, or 18.66px bold) needs 3:1, and non-text elements - input borders, meaningful icons, focus rings - need 3:1 against adjacent colors. Measure at the worst point of a gradient or photo, not the average. |
| Both orientations work | High | WCAG 1.3.4 (Orientation). Never lock to portrait or landscape through the manifest's orientation field or the Screen Orientation API unless the content is impossible otherwise, such as a piano keyboard. Users with a device mounted to a wheelchair cannot rotate it. |
| Text scales to 200% without loss | High | WCAG 1.4.4 (Resize Text). Set type in rem, never px, and never put maximum-scale=1 or user-scalable=no in the viewport meta. At 200% browser text size nothing clips, overlaps or is cut off by a fixed-height container. |
| Every accesskey is unique | Medium | Duplicate accesskey values leave the browser to pick one and the other shortcut silently dies. Most sites should not use accesskey at all - it collides with screen reader and browser shortcuts - but if you do, one value per page, documented somewhere users can find it. |
| Frames and iframes have a title | Medium | Every <iframe> carries a title attribute that says what is inside ("Payment form", "Map of the office"). Screen readers announce it on entering the frame; without it the user hears "frame" and a URL. |
| lang and xml:lang agree | Medium | If the root element carries both lang and xml:lang (XHTML syntax, or a copy-pasted template), they must hold the same value. Simplest fix: drop xml:lang and keep <html lang="en">. Mark inline language changes with lang on the element (WCAG 3.1.2). |
| Links with the same text go to the same place | Medium | WCAG 3.2.4 (Consistent Identification). Twelve "Read more" links pointing at twelve articles are ambiguous in a link list; two "Contact" links going to different pages are worse. Make link text unique per destination, or make identical links share one. |
| Exactly one main landmark | Medium | One <main> per page, not nested and not inside <header>, <nav> or <footer>. It is the target of the skip link and the "jump to content" shortcut; two of them, or none, sends that shortcut somewhere arbitrary. |
| Landmarks are used as designed | Medium | <header> and <footer> as direct children of body (banner and contentinfo), <nav> for navigation, <aside> for complementary content, <section> only becomes a landmark when it has a name. All content lives inside some landmark, and banner and contentinfo are never nested inside another one. |
| Links inside text are distinguishable without color | Medium | WCAG 1.4.1 (Use of Color). A link inside a paragraph needs a second cue - the default underline, a border-bottom, an icon - or 3:1 contrast against the surrounding text plus a visible change on hover and focus. Removing the underline and picking a slightly different blue fails. |
| No meta refresh redirects | Medium | <meta http-equiv="refresh"> pulls the page away on a timer the user cannot control (WCAG 2.2.1) and pollutes back-button history. Redirect with an HTTP 301 or 302 from the server or edge; for a timed step, show a button. |
| Objects and embeds have a text alternative | Medium | <object> and <embed> need fallback content between the tags or an aria-label, exactly as an <img> needs alt. An embedded PDF viewer or chart with no fallback is a silent block of nothing to a screen reader. |
| tabindex is 0 or -1, never positive | Medium | tabindex="0" adds a custom widget to the natural order; tabindex="-1" makes a target focusable by script only. Any positive value jumps ahead of every other focusable element on the page and breaks the reading order (WCAG 2.4.3). Fix the DOM order instead. |
| Pointer targets are at least 24 by 24 CSS pixels | Medium | WCAG 2.5.8 (Target Size Minimum). Every button, link and control is 24px in each dimension, or has 24px of clear space around it; 44px is the comfortable size for touch. Inline text links are exempt; icon buttons, pagination and close buttons are the usual failures. |
| Alt text does not repeat what the role already says | Low | Screen readers announce "image" before the alt text, so alt="Image of the team" reads as "image, image of the team". Describe the content or function and drop "image of", "photo of" and "icon". |
| No images of text | Low | WCAG 1.4.5. Text belongs in HTML so it can be resized, reflowed, translated, searched and read aloud; a PNG of a headline, a pricing table or a quote does none of that. Logos are the one exemption. Use web fonts and CSS for styled text. |
The focus ring is the non-text contrast case that fails most often (WCAG 2.4.7 and 1.4.11). A two-tone ring passes on light and dark backgrounds without a per-component color:
/* Keyboard focus: an inner white ring and an outer dark ring,
so it reads at 3:1 whatever sits behind it */
:focus-visible {
outline: 3px solid #fff;
outline-offset: 0;
box-shadow: 0 0 0 6px #1d4ed8;
}
/* Remove the ring for mouse clicks only - never for keyboard focus */
:focus:not(:focus-visible) {
outline: none;
box-shadow: none;
} Gotcha: the viewport meta tag is where zoom dies. maximum-scale=1 and user-scalable=no disable pinch zoom on every mobile browser that honors them, which fails 1.4.4 on its own and turns every small-text row above into a hard blocker. width=device-width, initial-scale=1 is the whole tag.
Components
The widgets HTML does not ship natively - or ships and teams rebuild anyway - each with a documented keyboard contract in the ARIA Authoring Practices Guide.
| Rule | Priority | What to do |
|---|---|---|
| Modal dialogs trap focus, close on Escape and return focus | High | Use <dialog> and showModal(): focus moves inside, the rest of the page goes inert, Escape closes it and focus returns to the opener. A custom overlay owes all four in JavaScript plus role="dialog", aria-modal="true" and an accessible name. Follow the APG dialog pattern. |
| Notifications announce themselves | High | A toast in a corner is invisible to a screen reader unless it lands in a live region. Render a persistent role="status" container, write each message into it, and keep the toast on screen until dismissed for anything that has an action (WCAG 2.2.1). Errors go in role="alert". |
| Tooltips are hoverable, dismissible and keyboard-reachable | Medium | WCAG 1.4.13 (Content on Hover or Focus). The tooltip shows on focus as well as hover, stays open while the pointer moves onto it, and closes on Escape without moving focus. Give it role="tooltip" and point the trigger at it with aria-describedby. Never put interactive content inside a tooltip. |
| Accordions work from the keyboard | Medium | Each header is a <button> inside a heading element, with aria-expanded and aria-controls; Enter and Space toggle it. <details> and <summary> give you the whole thing natively, including find-in-page opening a closed panel. |
| Carousels can be paused and driven by the keyboard | Medium | WCAG 2.2.2 (Pause, Stop, Hide). Auto-advancing slides need a visible pause control, must stop on hover and focus, and must not advance while a slide has focus. Previous, next and slide-picker controls are real buttons with names, and the region carries aria-roledescription="carousel" with an aria-label. |
| Tabs follow the ARIA tabs pattern | Medium | role="tablist" containing role="tab" buttons with aria-selected and aria-controls; each panel is role="tabpanel" with aria-labelledby back to its tab. Arrow keys move between tabs, Tab moves into the panel - one tab stop for the whole list, via a roving tabindex. |
The native element does the four hard parts of a modal for free:
<button type="button" class="open-settings">Settings</button>
<dialog class="settings-dialog" aria-labelledby="settings-title">
<h2 id="settings-title">Settings</h2>
...
<button type="button" class="close-settings">Close</button>
</dialog>
// showModal() traps focus, makes everything behind it inert,
// closes on Escape, and returns focus to the opener on close
const dialog = document.querySelector('.settings-dialog');
document.querySelector('.open-settings').onclick = () => dialog.showModal();
document.querySelector('.close-settings').onclick = () => dialog.close(); Gotcha: show() and showModal() are not interchangeable. show() opens a non-modal dialog with no focus trap, no inert background and no Escape handling. If the rest of the page must be unreachable while it is open, it is showModal() every time.
Content
The words are part of the interface: link text, instructions and error copy are read out of context far more often than designers assume.
| Rule | Priority | What to do |
|---|---|---|
| Link text says where the link goes | High | WCAG 2.4.4 (Link Purpose). "Download the 2026 pricing sheet (PDF)" rather than "click here". Screen reader users pull up a list of every link on the page, out of context, so the text has to stand alone. When the design insists on "Read more", add the article title in visually hidden text or with aria-labelledby. |
| Instructions never rely on one sense | High | WCAG 1.3.3 (Sensory Characteristics). "Press the green button on the right" fails for anyone who cannot see green, right, or the button. Reference the control by its name - "press Save" - and pair every color, shape, size, position or sound cue with text. |
| No empty or broken links | Medium | An <a> with no text, no image alt and no aria-label is announced as its URL or as "link" alone. An <a> without href is not a link at all - not focusable, no role. Crawl for empty anchors and 404s in the build, and use a <button> when nothing navigates. |
| Use inclusive language | Medium | Address the user directly, avoid idioms that do not translate, name people as they describe themselves, and drop gendered defaults from placeholder text and examples. Error messages blame the situation ("That code has expired"), never the person. |
| Write in plain language | Medium | Short sentences, common words, one idea per paragraph, the point first. Expand every acronym on first use and aim for a lower-secondary reading level on general pages - the WCAG 3.1.5 target, AAA but cheap. Plain text is also what screen readers, translation and search handle best. |
Gotcha: uppercase through CSS text-transform is fine; uppercase typed into the HTML gets some screen readers spelling short words out letter by letter as if they were acronyms. Keep source text in sentence case and let the stylesheet shout.
Animation
Motion is the one category that can physically harm a user, so the defaults run the other way: still unless the user has opted in.
| Rule | Priority | What to do |
|---|---|---|
| Nothing flashes more than three times a second | Critical | WCAG 2.3.1 (Three Flashes or Below Threshold). No element, video or animation flashes more than three times in any one-second period; this can trigger seizures and is the one rule with direct physical harm behind it. Run video through PEAT, the Photosensitive Epilepsy Analysis Tool, before publishing. |
| Honor prefers-reduced-motion | High | WCAG 2.3.3 (Animation from Interactions) is AAA, and honoring the setting costs one media query. Gate every non-essential transition, parallax, auto-playing background video and smooth scroll behind @media (prefers-reduced-motion: no-preference) so the default is still. Vestibular disorders turn large movement into nausea and dizziness. |
| Parallax has a still alternative | Medium | Parallax is the movement most likely to trigger vestibular symptoms because background and foreground move at different rates. Disable it under prefers-reduced-motion: reduce, and never attach it to scrolling that is required to read the content. |
| Keep native scrolling | Medium | No scrolljacking: no wheel handlers that replace scrolling with full-page transitions, no altered scroll speed, no scroll-driven horizontal panning. Native scrolling honors the user's settings, assistive tech and keyboard. scroll-snap-type is the acceptable version, and proximity beats mandatory for anything longer than a screen. |
| Anchor scrolling respects the motion setting | Low | If you set scroll-behavior: smooth on html, wrap it in prefers-reduced-motion: no-preference or reset it to auto under reduce, so in-page links and the skip link jump instantly for anyone who asked for less motion. |
Opt in to motion rather than opting out, so the still version is the default and nothing needs an override:
/* Still by default */
.hero-art { transform: none; }
/* Animate only for people who have not asked for less motion */
@media (prefers-reduced-motion: no-preference) {
.hero-art {
animation: float 6s ease-in-out infinite;
}
html {
scroll-behavior: smooth;
}
} Gotcha: reduced motion means reduce, not remove. Opacity fades and short transitions are fine; large translations, zooms and parallax are not. Killing every transition with a 0.01ms !important reset also kills the transitionend events your JavaScript may be waiting on, so a component that waits for its animation to finish never opens.
Media
Audio and video need a text equivalent for every channel they carry - the dialogue, the sound, and what is on screen.
| Rule | Priority | What to do |
|---|---|---|
| Nothing autoplays with sound | High | WCAG 1.4.2 (Audio Control) and 2.2.2. Audio that starts on its own must be stoppable within three seconds or must not start at all, and a muted background video still needs a pause control if it runs longer than five seconds. Browsers block unmuted autoplay anyway - design for it not happening. |
| Video has captions | High | WCAG 1.2.2 (Captions, Prerecorded). Add a <track kind="captions" srclang="en"> WebVTT file to every <video> with dialogue, including speaker labels and meaningful sounds. Auto-generated captions are a draft to edit, not a deliverable. Live video needs live captions (1.2.4). |
| Video has audio description | Medium | WCAG 1.2.5 (Audio Description, Prerecorded) is Level AA: visual information the soundtrack does not carry - text on screen, actions, charts - is narrated in a separate audio track or an alternate version of the video. Publish a full transcript too; it is cheap and it is the only version a deafblind user can read. |
Gotcha: kind="subtitles" and kind="captions" are not interchangeable. Subtitles translate the dialogue for people who can hear; captions also describe the sound for people who cannot. A subtitles track on its own does not satisfy 1.2.2.
Interaction, navigation and testing
The remaining rules from the source's interaction, navigation and screen reader categories - including the only one on the page that cannot be automated at all.
| Rule | Priority | What to do |
|---|---|---|
| Every button has an accessible name | Critical | WCAG 4.1.2. Text content is the name; for an icon-only button use aria-label="Close" or visually hidden text, for <input type="submit"> the value attribute, for an image button the alt. A button whose only child is an SVG with no title is announced as "button" and nothing else. |
| Warn before a session times out and keep the data | High | WCAG 2.2.1 (Timing Adjustable). Warn at least 20 seconds before expiry with a one-click extend, or let the user switch the limit off. When a session does end, preserve what was typed and restore it after re-authentication instead of discarding the form. |
| Test with real screen readers | High | Automated tools catch the markup errors, not the experience. Before launch, run the critical journeys with NVDA and Firefox on Windows, VoiceOver and Safari on macOS and iOS, and TalkBack on Android - the pairings real users run. Twenty minutes per flow finds what axe cannot, and the free ones cover most of the market. |
| Drag and drop has a keyboard and single-pointer alternative | Medium | WCAG 2.5.7 (Dragging Movements, AA in 2.2). Every drag - reorder a list, move a card between columns, set a range - is also achievable with named, focusable buttons ("Move up", "Move to Done") or a menu. Sliders get arrow-key steps and a numeric input. |
| Help is in the same place on every page | Medium | WCAG 3.2.6 (Consistent Help, new in 2.2). If pages offer a contact link, chat widget, phone number or help button, it appears in the same relative position across the set - footer is footer, corner is corner. Moving it per template forces users to hunt for it every time. |
Gotcha: a screen reader and a browser are a pairing, not two independent choices. NVDA and JAWS with Chrome or Firefox, VoiceOver with Safari. Testing VoiceOver in Chrome finds bugs Safari users never see and misses the ones they do.
Keep going
The Critical rows above reappear in the launch checklist alongside the other disciplines' blockers, which is the shorter list to run the night before a release. The patterns these rules assume - which element to use instead of a div, the attributes worth using, the WCAG AA numbers, the five-minute keyboard test - are in the accessibility cheatsheet, and the document-level rules that feed into this page (doctype, charset, lang, semantic elements, form validation markup) are in the HTML checklist.
Focus rings, relative units and reflow are CSS decisions, covered in the CSS checklist. Before adopting a widget kit, the component library guide lists the red flags - an accessibility claim without a keyboard test is the first. Wiring axe-core into Playwright so the ARIA rows never regress is in the testing checklist.
The primary sources: WCAG 2.2 for every success criterion named on this page, and the ARIA Authoring Practices Guide for the keyboard contract of every widget in the Components group. The rest of the section starts at the frontend hub.