Part of our Compliance & Regulation series
Read the complete guideWeb Accessibility: WCAG 2.1 AA Compliance Guide
Accessibility is not a feature you add after launch — it is a fundamental quality attribute, the same as performance or security. WCAG 2.1 AA compliance is now legally required in the EU (European Accessibility Act, enforced June 2025), the US (ADA Title III case law), and many other jurisdictions. Beyond compliance, accessible interfaces convert better, rank higher in search, and serve an estimated 1.3 billion people worldwide with disabilities.
This guide is a practical implementation manual, not a checklist. You will learn the four WCAG principles, the most impactful techniques, how to test systematically, and how to integrate accessibility into your React/Next.js development workflow so it stays fixed.
Key Takeaways
- WCAG 2.1 AA requires all four POUR principles: Perceivable, Operable, Understandable, Robust
- Start with semantic HTML — it provides 70% of accessibility for free before any ARIA is added
- Minimum color contrast ratio: 4.5:1 for normal text, 3:1 for large text (18pt/14pt bold)
- Every interactive element must be keyboard focusable with a visible focus indicator
- Screen readers announce based on the accessibility tree — test with NVDA (Windows) and VoiceOver (Mac)
- ARIA is a last resort — it only changes how assistive technologies interpret the DOM, not behavior
- Automate with axe-core in your CI pipeline; manual testing catches what automation misses
- Document your accessibility statement and provide a feedback mechanism for users to report issues
The Four POUR Principles
WCAG 2.1 is organized around four principles. Every success criterion belongs to one of them.
Perceivable: Information must be presentable in ways users can perceive. This covers text alternatives for images, captions for video, sufficient color contrast, and content that doesn't rely on color alone to convey meaning.
Operable: All functionality must be operable via keyboard, with enough time to interact, no seizure-triggering content, and navigable structure (skip links, page titles, focus order).
Understandable: Content must be readable and predictable. Language must be identified, error messages must be descriptive, and forms must have clear labels and validation feedback.
Robust: Content must be interpretable by current and future assistive technologies. This means valid HTML, proper ARIA usage, and status messages that are announced without requiring focus.
Semantic HTML First
Semantic HTML is the single highest-leverage accessibility investment. Native HTML elements come with built-in accessibility roles, states, and keyboard behavior — no ARIA required.
// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
<div class="link" onclick="navigate('/home')">Home</div>
</div>
// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
<a href="/home">Home</a>
</nav>
Landmark regions help screen reader users navigate quickly by jumping between sections:
// Every page should have these landmarks
<header> {/* banner landmark */}
<nav aria-label="Main">...</nav>
</header>
<main> {/* main landmark */}
<h1>Page Title</h1>
<article>...</article>
<aside aria-label="Related content">...</aside>
</main>
<footer> {/* contentinfo landmark */}
<nav aria-label="Footer">...</nav>
</footer>
Heading hierarchy must be logical and unbroken:
// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3> {/* Skipped h2! */}
// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
Color Contrast
WCAG 2.1 AA requires:
- 4.5:1 contrast ratio for normal text (below 18pt / 14pt bold)
- 3:1 contrast ratio for large text (18pt+ / 14pt+ bold)
- 3:1 for UI components and graphical objects (buttons, icons, input borders)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>
// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>
// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>
Use the WebAIM Contrast Checker or the browser DevTools contrast tool during development. Add this to your Storybook or design tokens system to catch regressions:
// contrast-checker.ts
import { getContrast } from 'polished';
function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
const ratio = getContrast(fg, bg);
const required = level === 'AA' ? 4.5 : 7;
if (ratio < required) {
throw new Error(
`Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
);
}
}
Keyboard Navigation
Every interactive element — links, buttons, form fields, custom widgets — must be reachable and operable via keyboard.
Focus Management
// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
focus:rounded focus:ring-2 focus:ring-white"
>
Skip to main content
</a>
);
}
// Main content target
<main id="main-content" tabIndex={-1}>
{/* tabIndex={-1} allows programmatic focus without appearing in tab order */}
Focus Trapping in Modals
When a dialog opens, focus must be trapped inside it. When closed, focus returns to the trigger:
// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';
export function AccessibleModal({ trigger, children, title }: Props) {
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<Dialog>
<DialogTrigger ref={triggerRef} asChild>
<button>Open</button>
</DialogTrigger>
<DialogContent
// shadcn Dialog handles focus trap and returns focus to trigger on close
aria-describedby="dialog-description"
>
<DialogTitle>{title}</DialogTitle>
<p id="dialog-description" className="sr-only">
{/* Screen reader description of dialog purpose */}
</p>
{children}
</DialogContent>
</Dialog>
);
}
Visible Focus Indicators
WCAG 2.1 SC 2.4.11 (AA in WCAG 2.2) requires a minimum 2px focus outline. Never suppress focus without a replacement:
/* globals.css */
/* NEVER do this: */
:focus { outline: none; }
/* DO this: custom, visible focus ring */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 4px;
}
/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
outline: none;
}
ARIA: When and How to Use It
ARIA (Accessible Rich Internet Applications) attributes modify how assistive technologies interpret the DOM. The first rule of ARIA: do not use it if a native HTML element exists for your use case.
ARIA Labels
// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
<X className="h-4 w-4" aria-hidden="true" />
</button>
// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />
// Input with visible description
<input
id="password"
type="password"
aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>
ARIA Live Regions
Announce dynamic content changes without moving focus:
// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
return (
<>
{/* aria-live="polite" waits for user to finish current action */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{loading ? 'Loading results...' : `${count} results found`}
</div>
{/* Visual result count (not for screen readers — aria-hidden) */}
<span aria-hidden="true">{count} results</span>
</>
);
}
// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
return (
<div
role="alert"
aria-live="assertive"
className={cn('text-red-500 text-sm', !error && 'hidden')}
>
{error}
</div>
);
}
ARIA for Custom Widgets
When you must build a custom widget (tab panel, tree view, combobox), follow the ARIA Authoring Practices Guide (APG) patterns exactly:
// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
const [active, setActive] = useState(0);
return (
<div>
<div role="tablist" aria-label="Content tabs">
{items.map((item, i) => (
<button
key={item.id}
role="tab"
aria-selected={active === i}
aria-controls={`panel-${item.id}`}
id={`tab-${item.id}`}
tabIndex={active === i ? 0 : -1} // Roving tabindex
onClick={() => setActive(i)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
}}
>
{item.label}
</button>
))}
</div>
{items.map((item, i) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={active !== i}
>
{item.content}
</div>
))}
</div>
);
}
Forms and Error Handling
Accessible forms are among the highest-impact improvements for users with cognitive and motor disabilities.
// Accessible form field with error state
function TextField({
id,
label,
error,
required,
hint,
...props
}: TextFieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div>
<label htmlFor={id} className="font-medium">
{label}
{required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
{required && <span className="sr-only">(required)</span>}
</label>
{hint && (
<p id={hintId} className="text-sm text-gray-500 mt-1">
{hint}
</p>
)}
<input
id={id}
aria-required={required}
aria-invalid={!!error}
aria-describedby={describedBy}
className={cn('input', error && 'border-red-500')}
{...props}
/>
{error && (
<p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
{error}
</p>
)}
</div>
);
}
Images and Media
// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />
// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />
// Complex image — use aria-describedby for long descriptions
<figure>
<img
src="/architecture.png"
alt="System architecture diagram"
aria-describedby="arch-desc"
/>
<figcaption id="arch-desc">
The diagram shows three tiers: frontend Next.js on port 3000,
NestJS API on port 3001, and PostgreSQL database on port 5433.
Redis sits between the API and database layers.
</figcaption>
</figure>
// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
<use href="#icon-search" />
</svg>
Automated Testing with axe-core
pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Homepage accessibility', () => {
test('should have no WCAG 2.1 AA violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('#third-party-widget') // Exclude known external violations
.analyze();
expect(results.violations).toEqual([]);
});
test('should be keyboard navigable', async ({ page }) => {
await page.goto('/');
// Tab through interactive elements and verify focus is visible
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(
() => document.activeElement?.getAttribute('href')
);
expect(focusedElement).toBe('#main-content'); // Skip link
});
});
Add to CI pipeline:
# .github/workflows/ci.yml
- name: Run accessibility tests
run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
with:
name: a11y-report
path: apps/web/playwright-report/
Frequently Asked Questions
What is the difference between WCAG 2.1 A, AA, and AAA?
Level A is the minimum — failing Level A means the content is inaccessible to some users in fundamental ways. Level AA is the legal standard in most jurisdictions and targets the broadest user needs. Level AAA is aspirational — some criteria cannot be met for all content types. Target AA compliance as your baseline and aim for AAA where practical.
Does using a component library like shadcn/ui make my app accessible?
shadcn/ui is built on Radix UI primitives, which are accessible by design — they include correct ARIA roles, keyboard navigation, and focus management. However, you still need to add meaningful labels, handle error states accessibly, ensure sufficient color contrast with your custom theme, and test with real assistive technologies. Component libraries reduce the burden but do not eliminate the need for accessibility testing.
How do I test with a screen reader?
On Windows, use NVDA (free) with Firefox or Chrome. On macOS, use VoiceOver (built-in, Cmd+F5) with Safari. On mobile, use TalkBack (Android) or VoiceOver (iOS). Test key user journeys: form completion, modal interactions, navigation via landmarks, and reading dynamic content. Screen reader testing catches announcements, reading order, and focus behavior that automated tools miss.
What is the roving tabindex pattern?
Roving tabindex is the keyboard pattern for composite widgets (tab lists, toolbars, radio groups, tree views). Only one item in the group has tabIndex={0} at a time — the active item. All others get tabIndex={-1}. Arrow keys move focus within the group and update which item has tabIndex 0. This prevents the user from tabbing through every item in the group — they enter the group with Tab, navigate with Arrow keys, and leave with Tab.
How do I handle accessibility for dynamic content loaded via AJAX?
Use aria-live regions for status updates (search results count, save confirmations). For full page section replacements, move focus to the new content's heading or container after loading. For loading states, use aria-busy="true" on the region being updated and an aria-live="polite" region to announce completion. Always test with a screen reader to verify announcements are clear and timely.
Next Steps
Web accessibility is an ongoing practice, not a one-time audit. Start by fixing your semantic HTML and color contrast, then layer in keyboard navigation and ARIA for complex widgets, and automate WCAG validation in your CI pipeline to catch regressions.
ECOSIRE builds WCAG 2.1 AA compliant web applications as a baseline standard on every project. If you need an accessibility audit or want to build compliant from the ground up, explore our frontend engineering services.
Written by
ECOSIRE Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.
Related Articles
Audit Preparation Checklist: Getting Your Books Ready
Complete audit preparation checklist covering financial statement readiness, supporting documentation, internal controls documentation, auditor PBC lists, and common audit findings.
Australian GST Guide for eCommerce Businesses
Complete Australian GST guide for eCommerce businesses covering ATO registration, the $75,000 threshold, low value imports, BAS lodgement, and GST for digital services.
Canadian HST/GST Guide: Province-by-Province
Complete Canadian HST/GST guide covering registration requirements, province-by-province rates, input tax credits, QST, place of supply rules, and CRA compliance.
More from Compliance & Regulation
Audit Preparation Checklist: Getting Your Books Ready
Complete audit preparation checklist covering financial statement readiness, supporting documentation, internal controls documentation, auditor PBC lists, and common audit findings.
Australian GST Guide for eCommerce Businesses
Complete Australian GST guide for eCommerce businesses covering ATO registration, the $75,000 threshold, low value imports, BAS lodgement, and GST for digital services.
Canadian HST/GST Guide: Province-by-Province
Complete Canadian HST/GST guide covering registration requirements, province-by-province rates, input tax credits, QST, place of supply rules, and CRA compliance.
Healthcare Accounting: Compliance and Financial Management
Complete guide to healthcare accounting covering HIPAA financial compliance, contractual adjustments, charity care, cost report preparation, and revenue cycle management.
India GST Compliance for Digital Businesses
Complete India GST compliance guide for digital businesses covering registration, GSTIN, rates, input tax credits, e-invoicing, GSTR returns, and TDS/TCS provisions.
Fund Accounting for Nonprofits: Best Practices
Master nonprofit fund accounting with net asset classifications, grant tracking, Form 990 preparation, functional expense allocation, and audit readiness best practices.