Home/Blog

Web Accessibility for Developers: A WCAG 2.2 AA Guide

A developer's guide to WCAG 2.2 AA: semantic HTML, keyboard and focus, ARIA that works, contrast, reflow at 320px, target size and automated tests in CI.

Web Accessibility for Developers: A WCAG 2.2 AA Guide

An accessible web app is one people can use with a keyboard, a screen reader, 400 percent zoom, or a phone held in one hand, and the working target in 2026 is WCAG 2.2 Level AA. Most of that comes from semantic HTML used correctly. ARIA is for the few widgets HTML has no element for, and wrong ARIA is worse than none. Automated checks in CI catch the mechanical failures cheaply, but only a keyboard pass and a screen reader pass by a person catch the rest.

Key takeaways

  • Target WCAG 2.2 Level AA. It is backward compatible with 2.1 AA, which is what most current laws and procurement standards reference.
  • Native elements first. A real button gives you focus, keyboard activation and an accessible name for free; a div with a click handler gives you none of them.
  • Every interaction must work from the keyboard, with a focus indicator you can see and that is never hidden behind sticky headers or banners.
  • Content must reflow at 320 CSS pixels wide without horizontal scrolling. That is an accessibility requirement and also the most common way mobile layouts break.
  • Run axe-based checks in CI on every pull request, and do a manual keyboard and screen reader pass for each new interaction pattern.
  • Accessibility overlays do not fix inaccessible code. Fix the components.

WCAG 2.2 on one page

WCAG (Web Content Accessibility Guidelines) is organized around four principles (perceivable, operable, understandable, robust) and testable success criteria at three levels: A (minimum), AA (the standard target for legal and procurement purposes) and AAA (enhanced, not expected across whole sites).

WCAG 2.2 became a W3C Recommendation on 5 October 2023. It kept everything from 2.1 except 4.1.1 Parsing, which was removed as obsolete, and added nine criteria. Six of them are at Level A or AA, so they are part of the target:

CriterionLevelWhat it requiresTypical fix
2.4.11 Focus Not Obscured (Minimum)AAA focused element is not entirely hidden by author-created contentscroll-padding for sticky headers; cookie banners that do not cover content
2.5.7 Dragging MovementsAAAnything done by dragging can also be done with single pointer actionsButtons or click-to-place alongside drag and drop
2.5.8 Target Size (Minimum)AAPointer targets at least 24 by 24 CSS pixels, or enough spacing around themLarger hit areas on icon buttons and compact controls
3.2.6 Consistent HelpAHelp mechanisms appear in the same relative place across pagesA help link or chat entry point in a fixed location
3.3.7 Redundant EntryAUsers are not asked again for information they already gave in the same processPrefill, or offer to reuse earlier answers
3.3.8 Accessible Authentication (Minimum)AANo cognitive function test, such as remembering or transcribing, to log in unless an alternative existsAllow paste and password managers; support passkeys

The other three new criteria (2.4.12, 2.4.13 and 3.3.9) are AAA.

Where the law points

JurisdictionInstrumentTechnical standard referenced
European UnionEuropean Accessibility Act, applying from 28 June 2025 to many consumer products and servicesEN 301 549 V3.2.1, which references WCAG 2.1 AA. V4.1.1, published in September 2026, references WCAG 2.2 AA and takes over once cited in the Official Journal
US state and local governmentADA Title II rule from the Department of Justice (2024), with compliance dates in April 2027 or April 2028 depending on sizeWCAG 2.1 AA
US federal agenciesSection 508WCAG 2.0 AA
US private businessesADA Title III, mostly through litigationCourts and settlements commonly use WCAG 2.x AA

Building to WCAG 2.2 AA covers the 2.1 AA references, because 2.2 is a superset apart from the removed Parsing criterion. WCAG 3 is still a working draft and will not be a compliance target for years. This is engineering guidance, not legal advice.

Semantic HTML does most of the work

Assistive technologies read the accessibility tree that the browser builds from your HTML. Native elements arrive with roles, names, states and keyboard behavior built in. This markup has no roles, cannot be focused and ignores the keyboard:

<div className="nav">
  <div className="nav-item" onClick={() => router.push("/")}>Home</div>
  <div className="nav-item" onClick={() => router.push("/pricing")}>Pricing</div>
</div>
<div className="title">Our services</div>
<div className="btn" onClick={save}>Save</div>

The same content with elements that carry the semantics, and no ARIA needed:

<nav aria-label="Main">
  <ul>
    <li><a href="/">Home</a></li>
    <li><a href="/pricing">Pricing</a></li>
  </ul>
</nav>
<main>
  <h1>Our services</h1>
  <button type="button" onClick={save}>Save</button>
</main>

Rules that cover most pages:

  • Links navigate; buttons act. A link styled as a button is fine. A div pretending to be either is not.
  • One h1 per page, then headings in order without skipping levels. Headings are the main way many screen reader users scan a page.
  • Landmarks (header, nav, main, footer, aside) let users jump between regions. Give repeated landmarks distinct labels.
  • Every form control has a visible label tied to it. Placeholder text is not a label.
  • Images get alt text that says what the image is for. Decorative images get an empty alt.
  • Set lang on the html element so screen readers pronounce the content correctly.

Keyboard and focus

Every interaction must work with a keyboard alone (2.1.1), without trapping focus (2.1.2), in a sensible order (2.4.3), with a visible focus indicator (2.4.7) that is not hidden (2.4.11).

A focus style that is always visible

:focus-visible {
  outline: 3px solid var(--focus-ring, #2563eb);
  outline-offset: 2px;
}
 
/* Keep focused elements clear of sticky headers and footers (2.4.11) */
html {
  scroll-padding-top: 5rem;
  scroll-padding-bottom: 4rem;
}

Never remove the outline without replacing it. outline: none on :focus with nothing in its place is still a common failure in production CSS.

Dialogs: use the platform

A modal dialog has to move focus in, keep it in, close on Escape, and return focus to whatever opened it. The native dialog element opened with showModal() handles all four: it makes the rest of the page inert, keeps Tab inside the dialog, closes on Escape, and, per the HTML standard, returns focus to the previously focused element when it closes. The onClose handler below makes that return explicit.

function ConfirmDelete({ onConfirm }: { onConfirm: () => void }) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const openerRef = useRef<HTMLButtonElement>(null);
  const close = () => dialogRef.current?.close();
 
  return (
    <>
      <button ref={openerRef} type="button" onClick={() => dialogRef.current?.showModal()}>
        Delete project
      </button>
      <dialog
        ref={dialogRef}
        aria-labelledby="confirm-title"
        onClose={() => openerRef.current?.focus()}
      >
        <h2 id="confirm-title">Delete this project?</h2>
        <p>This removes the project and its history for everyone.</p>
        <button type="button" onClick={close} autoFocus>Cancel</button>
        <button type="button" onClick={() => { onConfirm(); close(); }}>Delete</button>
      </dialog>
    </>
  );
}

If you build modals from div elements instead, you own all four behaviors, plus making the background inert with the inert attribute.

Custom widgets: follow the pattern or do not build them

For widgets with no native element (tabs, comboboxes, menus, tree views), the WAI-ARIA Authoring Practices Guide (APG) defines the expected keyboard behavior and ARIA. Assistive technology users rely on that consistency. Tabs, for example, are a single Tab stop, with arrow keys moving between tabs through a roving tabIndex:

type Tab = { id: string; label: string; panel: React.ReactNode };
 
function Tabs({ tabs }: { tabs: Tab[] }) {
  const [active, setActive] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
 
  const onKeyDown = (e: React.KeyboardEvent) => {
    const last = tabs.length - 1;
    const next =
      e.key === "ArrowRight" ? (active === last ? 0 : active + 1) :
      e.key === "ArrowLeft" ? (active === 0 ? last : active - 1) :
      e.key === "Home" ? 0 :
      e.key === "End" ? last : null;
    if (next === null) return;
    e.preventDefault();
    setActive(next);
    refs.current[next]?.focus();
  };
 
  return (
    <>
      <div role="tablist" aria-label="Account settings" onKeyDown={onKeyDown}>
        {tabs.map((t, i) => (
          <button
            key={t.id}
            ref={(el) => { refs.current[i] = el; }}
            role="tab"
            id={`tab-${t.id}`}
            aria-selected={i === active}
            aria-controls={`panel-${t.id}`}
            tabIndex={i === active ? 0 : -1}
            onClick={() => setActive(i)}
          >
            {t.label}
          </button>
        ))}
      </div>
      {tabs.map((t, i) => (
        <div
          key={t.id}
          role="tabpanel"
          id={`panel-${t.id}`}
          aria-labelledby={`tab-${t.id}`}
          hidden={i !== active}
          tabIndex={0}
        >
          {t.panel}
        </div>
      ))}
    </>
  );
}

For anything more complex (comboboxes, date pickers, menus), use a library whose components implement the APG patterns and are tested with screen readers, such as React Aria or Radix UI. A custom combobox that works across screen readers is a substantial project, which is a good reason to prefer a native select when the design allows it. Our post on design systems and component libraries covers building this into shared components so each team does not reinvent it.

ARIA: the rules that prevent harm

The first rule of ARIA is not to use it when a native element does the job. When you do use it:

  • role changes what an element is announced as, not how it behaves. role="button" on a div still needs tabIndex, plus Enter and Space handling.
  • Every interactive element needs an accessible name: visible text, aria-label or aria-labelledby. Icon-only buttons are the usual offenders.
  • Never put aria-hidden="true" on anything focusable. Keyboard users can reach it while screen reader users hear nothing.
  • Keep states in sync. aria-expanded, aria-selected, aria-pressed and aria-invalid must reflect reality on every render.
<button type="button" aria-label="Close dialog" onClick={close}>
  <XIcon aria-hidden="true" />
</button>
 
<button type="button" aria-expanded={open} aria-controls="filters" onClick={() => setOpen(!open)}>
  Filters
</button>
<div id="filters" hidden={!open}>...</div>

Live regions for things that change

Toasts, async validation and "saved" messages are invisible to screen reader users unless they are announced (4.1.3 Status Messages). The region must already exist in the DOM when its content changes; inserting a region and its text at the same moment often produces no announcement at all. Render it once, near the root, and update its text:

<div aria-live="polite" aria-atomic="true" className="sr-only">
  {statusMessage}
</div>

Use role="alert" (assertive) only for errors that need immediate attention, because it interrupts whatever the user is listening to.

Forms and errors

<form noValidate onSubmit={onSubmit}>
  <label htmlFor="email">Work email</label>
  <input
    id="email"
    name="email"
    type="email"
    autoComplete="email"
    aria-invalid={errors.email ? true : undefined}
    aria-describedby={errors.email ? "email-error email-hint" : "email-hint"}
  />
  <p id="email-hint">We send invoices here.</p>
  {errors.email && <p id="email-error">Enter an email address like name@company.com</p>}
 
  <label htmlFor="password">Password</label>
  <input id="password" name="password" type="password" autoComplete="current-password" />
  {/* Never block paste on this field: password managers depend on it (3.3.8) */}
 
  <button type="submit">Sign in</button>
</form>

Error text must say what is wrong and how to fix it (3.3.1 and 3.3.3). When a submit fails validation, move focus to an error summary at the top of the form, or to the first invalid field, so keyboard and screen reader users know the submit did not go through. autocomplete tokens (1.3.5 Identify Input Purpose) let browsers and assistive tools fill common fields for users.

Color and contrast

  • Text contrast (1.4.3): at least 4.5:1 for normal text and 3:1 for large text. Large means at least 18 point (24 CSS pixels) regular, or 14 point (about 18.7 CSS pixels) bold.
  • Non-text contrast (1.4.11): at least 3:1 for UI component boundaries, focus indicators, and meaningful parts of graphics such as chart lines and input borders.
  • Use of color (1.4.1): never the only signal. Errors get text and an icon, chart series get labels or patterns, and links in body text get underlines.
  • Both themes. Dark mode tokens are where contrast regressions hide, because design reviews usually happen in the light theme.

Put contrast into design tokens and test the token pairs rather than each screen. If --text-muted on --surface passes in both themes, every use of that pair passes.

Reflow, zoom and target size

Reflow (1.4.10) requires content to work at a width equivalent to 320 CSS pixels without scrolling in two directions, which is what a 1280 pixel wide window looks like at 400 percent zoom. Content that genuinely needs two dimensions, such as data tables, maps and diagrams, is exempt. For everything else, the failure is the same one that breaks phone layouts: one element wider than the viewport makes the whole page scroll sideways.

The usual culprits and their fixes:

/* Flex and grid children default to min-width: auto and refuse to shrink */
.card-row > * { min-width: 0; }
 
/* Long URLs, tokens and email addresses */
.prose { overflow-wrap: anywhere; }
 
/* Media never wider than its container */
img, video, iframe { max-width: 100%; height: auto; }
 
/* Wide tables scroll inside their own region instead of widening the page */
.table-scroll { overflow-x: auto; }
<div className="table-scroll" role="region" aria-label="Plan comparison" tabIndex={0}>
  <table>...</table>
</div>

The scroll container gets tabIndex={0} and a label so keyboard users can scroll it and screen reader users know what it is. axe flags scrollable regions that keyboard users cannot reach.

Fixed widths are the other big source: a screenshot with a hard-coded pixel width, a min-width on a pricing table, a code block without overflow-x: auto. They are easy to miss because developers rarely work at 320 pixels wide. We built wontfit for exactly this: it renders your running app at phone widths side by side, names the element that overflows and by how many pixels, and can fail a pull request when a layout breaks. The wontfit launch post walks through a real run.

Two related criteria break the same layouts. Text spacing (1.4.12): the page must survive users increasing line height and letter and word spacing, which breaks containers with fixed heights. Resize text (1.4.4): text must scale to 200 percent without loss of content or function.

On target size, WCAG 2.2 AA requires pointer targets of at least 24 by 24 CSS pixels, or enough spacing that a 24 pixel circle centered on each small target does not overlap another target. Inline links in running text are exempt. The AAA criterion (2.5.5) asks for 44 by 44, close to the 44 point minimum in Apple's guidelines and the 48 dp minimum in Material Design, and that is the better design target for primary controls on touch screens.

Motion

Respect the reduced motion setting. Parallax, large slide transitions and auto-playing animation can trigger vestibular symptoms.

@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;
  }
}

In React with Motion, useReducedMotion() exposes the same preference, so components can drop transforms rather than only speeding them up. Anything that moves, blinks or scrolls automatically for more than five seconds alongside other content needs a way to pause it (2.2.2).

Testing that catches regressions

Automated checks in CI

Automated rules can decide only a subset of WCAG success criteria. They are good at missing names, contrast, missing labels and invalid ARIA, and they cannot judge whether alt text is meaningful or whether a focus order makes sense. That subset is exactly what regresses silently, so run it on every pull request.

Lint at write time with eslint-plugin-jsx-a11y, and scan rendered pages with axe in Playwright:

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
 
const pages = ["/", "/pricing", "/signup", "/dashboard"];
 
for (const path of pages) {
  test(`no WCAG A or AA violations on ${path}`, async ({ page }) => {
    await page.goto(path);
    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"])
      .analyze();
    expect(results.violations).toEqual([]);
  });
}

Scan the states that matter, not only the first render: open the menu, open the dialog, submit the form with errors, and scan again each time.

Keyboard tests

Keyboard behavior is testable too:

test("sign-in form works from the keyboard", async ({ page }) => {
  await page.goto("/signin");
  await page.getByLabel("Work email").focus();
  await page.keyboard.type("dev@example.com");
  await page.keyboard.press("Tab");
  await expect(page.getByLabel("Password")).toBeFocused();
  await page.keyboard.type("correct horse battery staple");
  await page.keyboard.press("Enter");
  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});

Manual passes

Before shipping a new interaction pattern, and on a regular cadence for key flows:

  • Keyboard only. Put the mouse away. Can you reach and operate everything, see where focus is at all times, and never get stuck?
  • Screen reader. NVDA with Firefox or Chrome on Windows, VoiceOver with Safari on macOS and iOS, TalkBack with Chrome on Android. Navigate by headings and landmarks, fill in a form, trigger an error.
  • Zoom and reflow. 200 percent text size, 400 percent page zoom, and a 320 pixel wide viewport.
  • Contrast in both themes, including hover, focus and disabled states.

Our testing strategies guide covers where these checks sit alongside unit, integration and end-to-end tests.

What goes wrong in production

  • The design system ships the bug to every page. One inaccessible button or input component, reused everywhere, is the most efficient way to fail an audit. Fix accessibility at the component level, with tests on the components.
  • Single-page navigation drops focus. After a client-side route change, focus stays on a link that no longer exists and screen readers announce nothing. Move focus to the new page's heading or main region, or rely on your framework's route announcer (Next.js includes one) and verify it works.
  • Toasts that vanish. Success and error messages that appear for a few seconds outside any live region are never heard and often never seen.
  • Cookie banners and chat widgets. Third-party overlays that trap focus, cover the focused element, or cannot be dismissed from the keyboard. Third-party code on your pages is still your responsibility.
  • Icon buttons with no name. The screen reader says "button" and nothing else. Lint for it.
  • Disabled buttons with no explanation. Users cannot tell why they cannot continue. Prefer an enabled button that shows validation messages.
  • Accessibility overlays. Scripts that promise automatic WCAG compliance do not fix the underlying markup, and they often interfere with users' own assistive technology. Fix the code.
  • No regression gate. A site audited and fixed once drifts back within a few releases. The axe suite in CI is what keeps the fixes in place.

Frequently Asked Questions

What is WCAG 2.2 and what changed from 2.1?

WCAG 2.2 is the current version of the W3C Web Content Accessibility Guidelines, published as a Recommendation in October 2023. It added nine success criteria, six of them at Level A or AA: Focus Not Obscured (Minimum), Dragging Movements, Target Size (Minimum), Consistent Help, Redundant Entry and Accessible Authentication (Minimum). It also removed 4.1.1 Parsing as obsolete, so a site that meets 2.2 AA also meets 2.1 AA.

Which WCAG level should a website meet?

Level AA is the standard target. It is what most accessibility laws, procurement rules and court settlements reference, and it includes every Level A criterion. Level AAA is not expected across entire sites, although individual AAA criteria, such as larger touch targets, are often worth adopting.

Is web accessibility a legal requirement?

In many places, yes. The European Accessibility Act has applied to many consumer products and services in the EU since 28 June 2025, US state and local governments must meet WCAG 2.1 AA under the 2024 ADA Title II rule from April 2027 or April 2028 depending on size, and US private businesses face ADA lawsuits that commonly use WCAG as the yardstick. The exact obligations depend on your sector and where your users are, so confirm them with counsel.

Can automated tools make a site accessible?

No. Automated checkers such as axe reliably catch mechanical failures like missing labels, missing alt attributes, low contrast and invalid ARIA, but many success criteria need human judgment, such as whether alt text is meaningful or focus order makes sense. Use automated checks in CI to stop regressions, and add manual keyboard and screen reader testing for each new interaction pattern.

What is the minimum color contrast ratio for WCAG AA?

Normal text needs a contrast ratio of at least 4.5:1 against its background, and large text needs at least 3:1. Large text means at least 18 point (24 CSS pixels) regular or 14 point (about 18.7 CSS pixels) bold. User interface components, focus indicators and meaningful graphics need at least 3:1 against adjacent colors.

Need help making your app accessible?

We build and run production web apps, and accessibility bugs are production bugs: they lock real users out. We fix them where they start, in shared components, then put axe, keyboard tests and layout checks into CI so the fixes survive the next release. If we built it, we are on the pager for it.