Accessibility on most teams I have worked with falls into one of two failure modes. Either it gets treated as a one-time audit done by a contractor a quarter before a major release, with the audit findings becoming a giant remediation backlog that quietly gets deprioritized, or it is treated as a vague company value that never makes contact with code review. Both modes ship inaccessible UIs. The fix that has worked for me is much smaller than either: a short checklist that takes me under five minutes per PR, applied as part of normal review, with the same enforcement weight as "does this typecheck".
My stance: accessibility is not hard, expensive, or specialist work for the vast majority of UI code. It is a finite list of common mistakes, and you can avoid every one of them by running the checklist. The reason teams ship inaccessible code is not technical, it is process: nobody has agreed that the checklist runs on every PR. Once the checklist is part of the workflow, the cost of writing accessible code is roughly the same as the cost of writing inaccessible code, and the accessibility wins compound.
The checklist itself
Here is the full thing, in the order I run through it. The rest of the article walks through each item with the why and the how-to.
Eleven items. Each one takes under a minute on a typical PR. Most PRs touch a subset, and you only check the ones the diff touches. The eleven items together cover roughly the WCAG 2.2 AA baseline that most accessibility laws (ADA in the US, EAA in the EU, AODA in Ontario) require.
Semantic HTML before ARIA
The rule that catches the most bugs in the cheapest way: use the HTML element that already does what you need. If you need a button, write <button>. If you need a link, write <a href="...">. If you need a form, write <form> with <input> and <label>. The browser gives you keyboard handling, focus management, screen reader semantics, and ARIA roles for free, and the free version is correct.
The anti-pattern that keeps showing up:
The div version cannot receive focus, cannot be activated with Enter or Space, has no role for screen readers, no disabled state, no submit-form behavior. To make the div equivalent to the button you need tabindex="0", role="button", an onKeyDown handler for Enter and Space, an aria-disabled attribute when disabled. That is four extra attributes on every fake button to recover what the real button gives you for nothing. Just use <button>.
The equivalent rule for navigation: a link is <a href="...">, not a div with an onClick that calls router.push. The link works with right-click open-in-new-tab, middle-click, ctrl+click, browser navigation, and screen readers; the div with onClick works with none of those.
The rule generalizes. <nav>, <main>, <header>, <footer>, <aside> for landmarks. <h1> through <h6> for headings (in order, no skipping). <ul>/<ol> for lists. <table> for tabular data with <th scope="col"> and <th scope="row"> headers. <dialog> (with the right showModal behavior) for modals. Each one carries semantics that ARIA can mimic with effort but rarely needs to.
My rule of thumb: if you find yourself typing role=, ask whether the right HTML element would have given you that role for free. Eight times out of ten it would have.
Keyboard navigation: tab through everything
The fastest test on the checklist: hit Tab repeatedly and walk through the page. Every interactive thing (buttons, links, inputs, custom controls) should receive focus in a sensible order. Every focused element should have a clearly visible indicator. The order should match what a user would expect: header navigation first, main content next, sidebar later.
Three common failures:
- Custom controls that do not receive focus. A custom dropdown built from divs that does not have
tabindex="0"on the trigger; the user tabs straight past it. The fix istabindex="0"on the trigger plus the right keyboard event handlers (Enter to open, arrows to navigate, Escape to close). - Skip links that do not work. A "Skip to main content" link inserted at the top of the page, but the link target does not exist or is not focusable. The fix is making the target a real focusable element (
<main id="main" tabindex="-1">) and ensuring the link sets focus correctly. - Tab order that does not match visual order. Absolute-positioned content that visually sits in the middle of the page but is tabbed to last because it is in a separate DOM subtree at the bottom. The fix is putting the DOM in reading order, not visual order.
Also check Shift+Tab: focus should move backward in reverse order. Test Escape on any modal, dropdown, or tooltip; it should close. Test Enter and Space on every focused control; they should activate it.
Visible focus indicators
Every focusable element needs a visible focus indicator. The default browser outline is ugly but at least visible; many design teams remove it (outline: none) without replacing it. That makes keyboard users invisible to themselves: they cannot see where their focus is, and the page is unusable.
The modern answer is :focus-visible, which shows the outline only for keyboard focus, not for click focus on buttons. That removes the visual noise designers complain about while preserving the indicator for keyboard users.
The contrast and size of the indicator matter. A focus ring that is the same color as the button's hover state, or a 1px outline that is hard to see against the background, both fail in practice. WCAG 2.2 introduced an explicit focus-appearance criterion: the indicator must have at least a 3:1 contrast against the unfocused state and be at least 2 CSS pixels thick on the longest side. A 2px outline-offset against a contrasting color usually clears that easily.
Forms: every input has a label
Every <input>, <textarea>, and <select> needs a programmatically-associated label. The screen reader user has no other way to know what the field is for. Three valid associations, in order of preference:
Placeholder text is not a label. Placeholders disappear when the user starts typing, lack contrast for some users, and are not announced as labels by all screen readers. Pattern: floating labels, with the label still rendered as a real <label> element, animated on focus or when the input has content. The label exists for accessibility; the animation is a presentation choice.
For error states: when validation fails, the error message must be programmatically associated with the input via aria-describedby, not just visually placed near it.
The role="alert" causes the error to be announced by screen readers as soon as it appears. Without that, the error is silently visible to sighted users and silently ignored by everyone else.
Color contrast: the math
WCAG 2.2 AA requires contrast ratios of:
- 4.5:1 for body text against its background.
- 3:1 for large text (>= 18 point or >= 14 point bold) and for non-text UI components (buttons, form borders, focus indicators).
The usual failures: light gray on white text ("placeholder gray" is often 2.5:1 or worse), button labels that are slightly off-spec, low-contrast disabled states. The browser DevTools (Chrome and Firefox) will compute the contrast ratio in the inspector for any text element; the value pops up next to the color in the styles pane. There is no excuse for shipping a 3:1 paragraph color when the tool tells you in real time.
The deeper rule: never convey meaning by color alone. "Required fields are red" is fine if there is also an asterisk or text label. "Errors are red" is fine if there is also an icon or message. A color-blind user (eight percent of men, half a percent of women) cannot distinguish the red from the green; if your only signal is color, they get nothing.
The screen reader smoke test
The quickest sanity check for any flow you change: turn on a screen reader and tab through the new UI with your eyes closed (or, more practically, with the screen reader's announcements as the only source of information). Mac has VoiceOver built in (Cmd+F5). Windows has Narrator (Ctrl+Win+Enter) and the free NVDA. Chrome has the ChromeVox extension. None of these is exotic; the activation is one keyboard shortcut.
What I listen for in the test:
- Are interactive controls announced with their role? "Submit, button" tells me a button is announced; "Submit, clickable" or just "Submit" tells me the role is missing.
- Are inputs announced with their label? "Email, edit text" tells me the label is associated; "edit text" alone tells me the label is missing.
- Are state changes announced? When I tap "Add to cart" and the count updates, do I hear "Added"? If not, the live region is missing or the visual update was silent.
- Can I complete the flow without the screen? The end-to-end test is the only one that catches "the visual flow works but the audio flow has a dead end". Sign-up flows, checkout flows, search flows are the high-value ones to actually walk through.
The smoke test does not need to be a full audit. Five minutes on the changed flow catches the things that are obviously broken; the audit catches the rest.
Modals and dynamic content
A few patterns that are subtle enough that I have a list:
Modals must trap focus. Tab inside the modal cycles through controls in the modal; it does not escape to the page underneath. Focus must move into the modal when it opens and back to the trigger element when it closes. The <dialog> element with showModal() does this for free; custom React modals usually need a focus-trap library (focus-trap-react is the common choice) and an escape-key handler.
Live updates must be announced. When the page updates content without a navigation (a chat message arrives, a toast appears, search results filter), the change must be in a live region (aria-live="polite" for non-urgent, aria-live="assertive" for urgent) so screen readers announce it.
Loading states must be announced. A spinner that appears during an async action is silent to screen readers unless paired with aria-busy="true" on the affected region or a live region announcing "Loading". The user otherwise has no idea anything is happening.
Dynamic content must be announced when it changes the page meaningfully. Tab content swaps, accordions expanding, autocomplete results appearing. Each one has a standard ARIA pattern (aria-expanded, aria-controls, aria-selected, listbox/option) that the WAI-ARIA Authoring Practices documents thoroughly. Use the documented pattern; do not invent your own.
Zoom and small viewports
Two tests that catch a lot of layout bugs at once:
- Set the browser zoom to 200%. The page must remain usable: text reflows, no horizontal scroll, controls remain reachable. WCAG 1.4.10 (Reflow) requires this for content up to 320 CSS pixels wide and 256 CSS pixels tall.
- Resize the viewport to 320 pixels wide. The same: no horizontal scroll, no overlapping content, no clipped text.
A grid layout with grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) collapses gracefully at 320 pixels. A grid layout with grid-template-columns: repeat(4, 1fr) does not. Use min() or clamp() for sizing that needs to be responsive.
Questions I get asked about this
Why not use an automated tool like axe? I do, and I recommend axe-core or eslint-plugin-jsx-a11y in CI to catch the mechanical errors. But automated tools catch only about 30 to 40 percent of accessibility issues; they miss the semantic ones (this control's name does not match its purpose, this flow has a dead end for keyboard users, the focus order is technically valid but logically nonsense). The checklist catches what tools cannot.
Is this all there is to accessibility? No, but it is most of what most teams need. WCAG 2.2 AA is roughly 50 success criteria; the checklist covers the dozen or so that come up daily in product UI. The rest (PDF accessibility, video captions, complex SVG charts, custom rich-text editors) are domain-specific and need additional rules. For the average React or Vue product, the eleven-item checklist covers the bulk of the legal and ethical baseline.
What about ARIA roles? Use them when there is no native HTML element, sparingly, and following the WAI-ARIA Authoring Practices patterns exactly. The first rule of ARIA is "do not use ARIA"; the second is "if you must, use the documented pattern, not your own". Most teams should not need to write much ARIA at all because most UI maps cleanly to native HTML.
How do I get the team to actually run the checklist? Make it part of the PR template. Eleven checkboxes; every reviewer is expected to confirm them. The friction of typing [x] in a checkbox is much lower than the friction of running an audit later. Once it is in the template, the conversations during review naturally become "is this checked, why not" instead of "did anyone think about accessibility on this PR".
