Skip to main content

Web Accessibility in 2026: The 80/20 Guide

· 15 min read
Pere Pages
Software Engineer
Web accessibility in 2026

Most of accessibility's real-world payoff comes from a handful of habits. This is the 20% of web accessibility work that delivers 80% of the results — the practices worth doing first.

Accessibility (often shortened to a11y — 11 letters between the "a" and "y") has a reputation for being a bottomless checklist. It isn't. The data says otherwise: the WebAIM Million analysis of the top one million homepages consistently finds that a tiny handful of issue types account for the overwhelming majority of detectable failures. Low-contrast text alone shows up on ~79% of pages; missing alternative text on ~55%. Six recurring issues cause the bulk of all problems.

That's the whole thesis of this post: a small, well-chosen set of practices gets you most of the way there. Fix those first, then invest in the harder, human-judgment parts.

This is written for a modern React + TypeScript + Vite stack, but the principles are framework-agnostic.


Part 1 — The 20% that gets you 80%

Ordered roughly by leverage. Every one of these is cheap to do and expensive to retrofit.

1. Use native, semantic HTML before anything else

The single highest-leverage habit. A native <button> gives you focusability, keyboard activation (Enter and Space), the correct role, and the right screen-reader announcement — for free. A <div onClick> gives you none of it, and "fixing" it means reimplementing the browser.

// ❌ Reinventing the browser badly
<div className={styles.btn} onClick={handleSave}>Save</div>

// ✅ Everything works out of the box
<button type="button" className={styles.btn} onClick={handleSave}>
Save
</button>

The same logic applies everywhere: <a href> for navigation, <nav>/<main>/<header>/<footer> for structure, <label> for form fields, <ul>/<ol> for lists, real headings for hierarchy. If you catch yourself adding role="button" + tabIndex={0} + onKeyDown, stop — the element you actually want already exists.

Rule of thumb: the best ARIA (Accessible Rich Internet Applications) is no ARIA. ARIA is a patch for when native HyperText Markup Language (HTML) genuinely can't express something (a tab widget, a combobox). For 90% of UI (User Interface), semantic HTML is the answer.

2. Text alternatives for images

alt is not optional metadata; for a screen-reader user it is the image. Two rules:

  • Informative image → describe its meaning, not its pixels.
  • Decorative image → empty alt="" so it's skipped (not missing alt, which makes some screen readers read the filename).
// Informative
<img src="/chart.png" alt="Revenue grew 34% from Q1 to Q4 2025" />

// Decorative — intentionally silent
<img src="/divider-flourish.svg" alt="" />

// Icon that conveys meaning inside a button (see #5)
<button aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>

Avoid alt="image of…" / alt="photo of…" — the assistive tech already announces "image."

3. Color contrast

The most common failure on the web, and one of the easiest to prevent at the design-token stage. The Web Content Accessibility Guidelines (WCAG) 2.2 AA targets:

  • 4.5:1 for normal text
  • 3:1 for large text (≥ 24px, or ≥ 18.66px bold) and for UI components / graphical objects (borders, icons, focus rings)
// tokens.scss — check these pairings once, reuse everywhere
$text-on-surface: #1a1a1a; // on #ffffff → ~17:1 ✅
$muted-text: #6b7280; // on #ffffff → ~4.8:1 ✅ (borderline, verify)
$disabled-text: #9ca3af; // on #ffffff → ~2.5:1 — OK, disabled is exempt

Bake contrast into your Cascading Style Sheets (CSS) variables / Sass tokens so it's decided once rather than per-component. (Note: WCAG 3.0's draft introduces a different contrast model, APCA (Advanced Perceptual Contrast Algorithm) — more on that in Part 2. Don't switch to it for compliance yet.)

4. Every form control has a programmatic label

A placeholder is not a label — it disappears on input and often fails contrast. Use a real <label> associated by htmlFor/id.

// ✅ Visible, associated label
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" autoComplete="email" />

// ✅ Visually hidden but still announced (e.g. a search field with only an icon)
<label htmlFor="q" className={styles.srOnly}>Search products</label>
<input id="q" type="search" />
// The canonical "visually hidden" utility — visible to screen readers only
.srOnly {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}

Also: use autoComplete tokens (email, name, tel, current-password…). They help everyone, and WCAG 2.2 expects them.

5. Accessible names for icon-only controls

An icon button with no text is silent to a screen reader. Give it a name and hide the decorative glyph.

function IconButton({ label, icon: Icon, onClick }: Props) {
return (
<button type="button" aria-label={label} onClick={onClick}>
<Icon aria-hidden="true" focusable="false" />
</button>
);
}

<IconButton label="Delete row" icon={TrashIcon} onClick={remove} />

6. Keyboard operability + visible focus

Everything you can do with a mouse must work with a keyboard, and users must always be able to see where they are.

// ❌ Never do this. It blinds keyboard users.
*:focus { outline: none; }

// ✅ Style the focus ring instead of removing it.
// :focus-visible shows it for keyboard nav but not on mouse click.
:focus-visible {
outline: 2px solid var(--focus-ring, #2563eb);
outline-offset: 2px;
border-radius: 2px;
}

Two related high-value additions:

Skip link — lets keyboard users jump past the nav on every page (WCAG 2.4.1). Cheap, huge payoff.

// First focusable element in the layout
<a href="#main" className={styles.skipLink}>Skip to main content</a>
// ...
<main id="main" tabIndex={-1}>{children}</main>
.skipLink {
position: absolute;
left: -9999px;
}
.skipLink:focus {
left: 1rem;
top: 1rem;
z-index: 100;
}

7. Document structure: lang, headings, landmarks

<!-- One line, catches a whole class of screen-reader mispronunciation issues -->
<html lang="en">

Use exactly one <h1> per view and don't skip levels (h2 → h4 confuses navigation). Screen-reader users jump between headings the way sighted users skim — a logical outline is real user experience (UX), not decoration.

8. Respect motion preferences

Vestibular disorders make large animations genuinely painful. Honour the operating system (OS) setting globally:

@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;
}
}
// Same idea in JS, for Framer Motion / imperative animations
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

The 80/20 scorecard

If you do only these, you eliminate the majority of what automated scanners (and lawsuits) flag:

PracticePrevents
Semantic HTMLRole/keyboard/name failures at the source
alt text~55% of pages' missing-text failures
Contrast tokens~79% of pages' #1 issue
Real <label>sUnlabelled form controls
Icon-button namesSilent controls
:focus-visible + skip linkKeyboard-only lockout
lang + headings + landmarksBroken screen-reader navigation
prefers-reduced-motionMotion-triggered harm

Part 2 — The most relevant a11y topics in 2026

The European Accessibility Act (EAA) became mandatory across European Union (EU) member states in June 2025. It reaches well beyond public-sector sites — e-commerce, banking apps, e-books, ticketing, and many private-sector digital products are in scope if you serve EU consumers. In the United States (US), Americans with Disabilities Act (ADA) web litigation keeps climbing, and a large share of suits target small and mid-sized businesses. Accessibility moved from "nice to have" to "regulated surface area."

WCAG 2.2 AA is the target — finally the default

WCAG 2.2 (published Nov 2023) is the current de-facto standard and is increasingly the baseline written into requests for proposal (RFPs) and procurement language. If your internal standard still says "2.1," 2026 is the year to move. The additions are pragmatic, not revolutionary: focus appearance, accessible authentication (don't force users to solve cognitive puzzles to log in), dragging alternatives, and consistent help placement.

WCAG 3.0 exists — but don't chase it yet

WCAG 3.0 (renamed W3C Accessibility Guidelines, from the World Wide Web Consortium (W3C)) is still a Working Draft, realistically finalising around 2028+, with regulatory adoption years after that. What's worth knowing now:

  • It replaces binary pass/fail with an outcome-based scoring model (Bronze/Silver/Gold-style tiers).
  • It shifts the unit of measurement from "pages" to views and processes — a much better fit for single-page applications (SPAs) and multi-step flows.
  • It introduces APCA (a perceptually-based contrast algorithm) as a candidate replacement for the 4.5:1 ratio math.
  • It adds real cognitive-accessibility guidance (plain language, findable help, reduced memory load). Practical stance: keep shipping against WCAG 2.2 AA. A solid 2.2 AA site is broadly aligned with WCAG 3.0's core. Any vendor selling "WCAG 3.0 certified" today is selling a roadmap. If you're doing a design-system refresh, it's reasonable to evaluate APCA in parallel — but not to switch your compliance target to it.

The quiet return to native HTML

After years of JavaScript-heavy, ARIA-laden custom widgets, the industry is drifting back toward native elements and browser-supported behaviours (<dialog>, <details>, popover, form validation, etc.). Less custom ARIA means fewer bugs, because the browser and assistive tech already agree on how native elements behave. This aligns perfectly with the 80/20 list above.

Respecting user preferences as a first-class concern

The mindset is shifting from "one accessible design" to "a design that adapts to what the user has already told their system." Beyond prefers-reduced-motion, plan for:

@media (prefers-contrast: more) { /* stronger borders/text */ }
@media (prefers-color-scheme: dark) { /* real dark mode, not inverted */ }

@media (forced-colors: active) {
/* Windows High Contrast Mode. Use system color keywords,
and don't kill borders that convey meaning. */
.card { border: 1px solid CanvasText; }
}

Designs that hard-code colors or override system settings increasingly read as broken, not just inaccessible.

SPA focus management

The classic React/Vue gotcha: client-side navigation swaps content but never moves focus or announces the change, so screen-reader users don't know the "page" changed. Automated scanners rarely catch it. Move focus to the new view's heading (or a live region) on route change:

function RouteAnnouncer() {
const { pathname } = useLocation();
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => {
ref.current?.focus();
}, [pathname]);
return <h1 ref={ref} tabIndex={-1}>{/* page title */}</h1>;
}

AI-assisted testing: a helper, not a replacement

Artificial intelligence (AI) is genuinely useful for triaging results — grouping related issues, prioritising by severity, drafting first-pass fixes. What it can't do is decide whether alt text is meaningful, whether a reading order makes sense, or whether a flow actually works for a human. A caution that's very 2026: an AI fix that turns a scanner green isn't the same as a fix that helps a person. Automated tools catch only ~30–40% of real WCAG issues; the rest is human judgment. Pair smart tooling with real testing.


Part 3 — The best tools (linters, plugins, libraries)

Think in layers (the a11y testing pyramid). Cheapest/fastest at the bottom, most thorough at the top. The goal is to catch as much as possible low in the stack so manual effort focuses on the nuanced stuff. Nearly everything below uses axe-core (by Deque) under the hood, so don't stack five tools that report the same violations — pick one per layer.

Layer 1 — Static analysis (linters) · highest effort-to-value ratio

eslint-plugin-jsx-a11y — non-negotiable for any JSX (JavaScript XML) app. Instant in-editor feedback: missing alt, invalid ARIA, click handlers without keyboard handlers, ambiguous link text, and more. ESLint 9 flat-config setup:

// eslint.config.js
import jsxA11y from 'eslint-plugin-jsx-a11y';

export default [
jsxA11y.flatConfigs.recommended,
// ...your other config
];

Biome — if you use Biome instead of ESLint/Prettier (fast, Rust-based), it ships a growing set of a11y lint rules built in. Fewer rules than jsx-a11y today, but zero-config and extremely fast.

Layer 2 — Component tests (Vitest) · targeted, runs in CI

vitest-axe — the Vitest-native port of jest-axe. Assert that a rendered component has no violations:

// vitest.setup.ts
import * as matchers from 'vitest-axe/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
// Button.test.tsx
import { render } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { expect, test } from 'vitest';
import { Button } from './Button';

test('Button has no a11y violations', async () => {
const { container } = render(<Button>Save</Button>);
expect(await axe(container)).toHaveNoViolations();
});

Gotcha: vitest-axe can be picky about the axe-core peer version and needs jsdom. Pin axe-core if you see matcher/type mismatches, and run these tests in the jsdom environment.

@testing-library/react (RTL) — not an a11y tool per se, but querying by role and accessible name (getByRole('button', { name: /save/i })) forces semantically correct markup. Adopt its philosophy and a11y improves as a side effect.

@axe-core/react — dev-only runtime checker that logs violations to the browser console as your app renders. Great for catching issues live during development (guard it behind a import.meta.env.DEV check so it never ships to production).

Layer 3 — End-to-end (Playwright) · whole-page, real browser

@axe-core/playwright — Deque's official Playwright integration. Scans fully-rendered pages across Chromium/Firefox/WebKit:

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('home page has no WCAG A/AA violations', async ({ page }) => {
await page.goto('/');
// wait for the app to actually finish rendering before scanning
await page.waitForSelector('[data-loaded="true"]');

const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();

expect(results.violations).toEqual([]);
});

Playwright also has native keyboard driving (page.keyboard.press('Tab')) for keyboard-walkthrough tests, and locator.ariaSnapshot() (since v1.49) which captures the accessibility tree as structured YAML (a human-readable data-serialisation format) — excellent for snapshotting a component's semantics rather than its HTML.

Layer 4 — Storybook · if you already use it

@storybook/addon-a11y — runs axe on every story and shows violations in a panel, plus color-blindness simulation filters. Near-zero setup, and it puts a11y feedback right where you build components. Can be wired into continuous integration (CI) via the test runner.

Layer 5 — CI / whole-site audits

  • Lighthouse CI — broad audit (perf + a11y) on every pull request (PR); set a minimum a11y score and ratchet it up over time.
  • pa11y / pa11y-ci — crawl a list of Uniform Resource Locators (URLs) and fail the build on violations; good for content-heavy or content-management-system (CMS) sites. Because your stack uses Docker, both run cleanly in a container step. A minimal GitHub Actions shape:
- run: npm run test # vitest + vitest-axe
- run: npx playwright test # @axe-core/playwright e2e
- run: npx lhci autorun # Lighthouse CI budgets

Layer 6 — Manual / browser tools · irreplaceable

  • axe DevTools (Chrome/Firefox extension) — the manual counterpart to axe-core.
  • WAVE (Web Accessibility Evaluation Tool) — visual overlay of issues directly on the page.
  • Accessibility Insights for Web — free, walks you through a guided WCAG 2.x assessment ("FastPass" + full assessment).
  • Lighthouse (built into Chrome DevTools) — quick per-page score.
  • Browser DevTools — the built-in Accessibility tree inspector and contrast checker in the color picker.

Layer 7 — Screen readers · test with the real thing

Screen readerPlatformCostNotes
NVDA (NonVisual Desktop Access)WindowsFreeThe most widely used; start here.
VoiceOvermacOS / iOSBuilt inCmd+F5.
JAWS (Job Access With Speech)WindowsPaidDominant in enterprise / government.

No automated tool replaces five minutes of actually tabbing through your app with a screen reader on.

Accessible-by-default component libraries

If you're building widgets that native HTML can't express (comboboxes, tabs, menus, date pickers), don't hand-roll the ARIA — start from a library that has solved it:

LibraryFrameworkStylingBest for
React Aria / React Aria Components (Adobe)ReactUnstyled (bring your own CSS)The gold standard for behaviour + accessibility.
Radix UI PrimitivesReactUnstyledPairs well with CSS Modules / Sass; base of many design systems.
Ark UIReact / Vue / SolidUnstyledFramework-agnostic accessible primitives.
Headless UIReact / VueUnstyled (Tailwind-adjacent)Lighter-weight, fewer components.

Unstyled/"headless" libraries are the sweet spot for your setup: you keep full control of styling in Sass/CSS Modules while inheriting battle-tested keyboard and ARIA behaviour.


If you adopt nothing else:

  1. eslint-plugin-jsx-a11y in your ESLint flat config — catches issues as you type.
  2. vitest-axe on your shared/complex components — cheap regression net in unit tests.
  3. @axe-core/playwright on 3–5 key pages/flows — full-page coverage in e2e.
  4. Lighthouse CI budget in your Docker CI step — a broad safety net per PR.
  5. A real screen-reader pass (NVDA or VoiceOver) before shipping anything interactive.
  6. For custom widgets, reach for React Aria or Radix instead of writing ARIA by hand. Automated layers catch ~30–40% of issues fast and cheaply; the last mile — meaningful alt text, sensible reading order, "does this actually work for a human" — is manual, and that's exactly where your remaining effort should go.