UI/UX & Design Engineering
Web Accessibility in Next.js: Meeting WCAG 2.1 AA Standards
Last updated: April 14, 2026
TL;DR
WCAG 2.1 AA is the minimum bar on every project I ship. Not because regulations demand it (though they increasingly do), but because excluding users by building interfaces only sighted mouse-users can operate is a failure of engineering. This guide covers the ten areas I focus on in every Next.js build: semantic HTML that gives assistive technology something to work with, keyboard navigation that doesn't trap users, focus management that survives client-side routing, color contrast ratios of 4.5:1 for body text and 3:1 for large text and UI elements, ARIA attributes used correctly (not sprinkled like decoration), screen reader testing workflows, and form patterns that actually tell users what went wrong and where. Every example is from production React code. If you're building with Next.js and want your interfaces to work for everyone, this is the guide I wish existed when I started.
Why Accessibility Matters Beyond Compliance
I'll be direct: I used to treat accessibility as a late-stage audit item. Build the feature, ship the UI, then run an axe scan and fix whatever it flags. That approach fails spectacularly, and here's why.
First, the numbers. The World Health Organization estimates that 16% of the global population lives with a significant disability. That's over 1.3 billion people. In the UK alone, the spending power of disabled households — the "purple pound" — exceeds 274 billion GBP annually. When your interface can't be navigated with a keyboard or understood by a screen reader, you're not just failing a compliance audit. You're locking out revenue and users.
Second, accessibility lawsuits are accelerating. The European Accessibility Act takes effect in June 2025. The ADA in the US already applies to websites — over 4,600 digital accessibility lawsuits were filed in 2023 alone. For client projects I build through iamuvin.com, WCAG 2.1 AA is a contractual baseline, not an optional enhancement.
Third — and this is the argument that changed my thinking — accessibility improvements make interfaces better for everyone. Keyboard navigation benefits power users. High contrast benefits anyone using a laptop in sunlight. Clear focus indicators help users on slow connections who tab through partially loaded pages. Accessible forms with proper error messages reduce support tickets for all users, not just those using assistive technology.
The lesson I've internalized: accessibility is not a feature. It's a quality attribute, like performance or security. You don't bolt it on at the end. You build with it from the first component.
WCAG 2.1 AA Requirements
WCAG 2.1 is organized around four principles — Perceivable, Operable, Understandable, and Robust (POUR). Level AA is the standard most regulations reference, and it's the bar I hold every project to. Here's what it actually demands, stripped of the specification jargon:
Perceivable — Can users sense the content?
- All non-text content (images, icons, charts) has text alternatives
- Video has captions; audio has transcripts
- Content can be presented in different ways without losing information (structure survives without CSS)
- Text has a contrast ratio of at least 4.5:1 against its background (3:1 for large text, 18px bold or 24px regular)
- Text can be resized to 200% without loss of content or functionality
- Content reflows at 320px viewport width without horizontal scrolling
Operable — Can users interact with it?
- All functionality is available from a keyboard
- No keyboard traps — users can always tab away
- Users have enough time to read and use content (no auto-advancing without controls)
- Nothing flashes more than three times per second
- Users can navigate, find content, and determine where they are
- Focus order is logical and meaningful
Understandable — Can users comprehend it?
- Text is readable and understandable (language is declared)
- Pages behave in predictable ways (no unexpected context changes on focus)
- Users are helped to avoid and correct mistakes (form validation with clear messages)
Robust — Does it work with assistive technology?
- Content is compatible with current and future assistive technologies
- HTML is well-formed with proper nesting
- Name, role, and value are programmatically determinable for all UI components
That's the specification. Now let me show you how these principles translate into Next.js code.
Semantic HTML in React
The single highest-impact accessibility practice is also the simplest: use the right HTML elements. A <button> is not a <div onClick>. A navigation menu is a <nav>, not a <div className="nav">. A heading hierarchy is <h1> through <h6>, not <p className="text-3xl font-bold">.
Semantic HTML gives assistive technology a structural map of your page. Screen readers use heading levels to build a page outline. Landmarks like <nav>, <main>, <aside>, and <footer> let users jump between sections. Interactive elements like <button> and <a> come with built-in keyboard support, focus management, and role announcements — for free.
Here's a layout pattern I use in every Next.js project:
// app/layout.tsx — semantic landmarks
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-md focus:bg-orange-500 focus:px-4 focus:py-2 focus:text-white"
>
Skip to main content
</a>
<header role="banner">
<nav aria-label="Primary navigation">
{/* Navigation items */}
</nav>
</header>
<main id="main-content" role="main" tabIndex={-1}>
{children}
</main>
<footer role="contentinfo">
{/* Footer content */}
</footer>
</body>
</html>
);
}A few things to notice. The skip link is the first focusable element on the page — keyboard users can bypass the navigation and jump straight to content. The lang="en" attribute on <html> tells screen readers which language to use for pronunciation. The tabIndex={-1} on <main> makes it programmatically focusable without adding it to the tab order — essential for focus management after route changes.
Here's a common anti-pattern I see in React codebases and the fix:
// Anti-pattern: div with click handler
function BadCard({ onClick, title }: { onClick: () => void; title: string }) {
return (
<div onClick={onClick} className="cursor-pointer rounded-lg p-4">
<p className="text-lg font-bold">{title}</p>
</div>
);
}
// Correct: semantic button or anchor
function AccessibleCard({
onClick,
title,
description,
}: {
onClick: () => void;
title: string;
description: string;
}) {
return (
<article className="rounded-lg p-4">
<h3 className="text-lg font-bold">{title}</h3>
<p className="text-muted">{description}</p>
<button
onClick={onClick}
className="mt-2 rounded-md bg-orange-500 px-4 py-2 text-white hover:bg-orange-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500"
>
View details
</button>
</article>
);
}The <div onClick> pattern breaks keyboard access (no focus, no Enter/Space activation), gives no role information to screen readers, and loses all the browser-native behavior that <button> provides. Every time I review code and see a clickable div, it gets replaced.
Keyboard Navigation
Every interactive element must be reachable and operable with a keyboard alone. This means: tab to navigate forward, Shift+Tab to go backward, Enter or Space to activate buttons, Enter to follow links, arrow keys for composite widgets like tabs and menus, and Escape to dismiss overlays.
The most common keyboard navigation failure I encounter is custom components that are visually interactive but invisible to the tab order. Here's a pattern I use for custom interactive elements:
// Accessible toggle switch
function Toggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-center gap-3">
<span className="text-sm font-medium">{label}</span>
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500 ${
checked ? "bg-orange-500" : "bg-gray-600"
}`}
>
<span
aria-hidden="true"
className={`inline-block h-5 w-5 translate-y-0.5 rounded-full bg-white shadow-sm transition-transform ${
checked ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</label>
);
}Notice role="switch" with aria-checked — this tells screen readers exactly what the element is and its current state. The decorative thumb span has aria-hidden="true" because the visual state is already communicated through aria-checked.
For keyboard shortcuts, I follow a consistent pattern using a custom hook:
import { useEffect, useCallback } from "react";
type KeyHandler = (event: KeyboardEvent) => void;
function useKeyboardShortcut(
key: string,
handler: KeyHandler,
options: { ctrl?: boolean; shift?: boolean; enabled?: boolean } = {}
) {
const { ctrl = false, shift = false, enabled = true } = options;
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!enabled) return;
if (ctrl && !event.ctrlKey && !event.metaKey) return;
if (shift && !event.shiftKey) return;
if (event.key.toLowerCase() !== key.toLowerCase()) return;
event.preventDefault();
handler(event);
},
[key, handler, ctrl, shift, enabled]
);
useEffect(() => {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
}One critical rule: focus indicators must be visible. The default browser outline is often removed with outline: none in resets. I always replace it with a visible custom focus style using focus-visible (which only shows for keyboard navigation, not mouse clicks):
/* Global focus style — every project */
:focus-visible {
outline: 2px solid var(--color-primary, #f7931a);
outline-offset: 2px;
border-radius: 2px;
}Focus Management in SPAs
Single-page applications break the traditional page navigation model. When a user clicks a link in a server-rendered site, the browser loads a new page and focus resets to the top. In a Next.js SPA with client-side routing, the URL changes but focus stays wherever it was — often on the link they just clicked, which now points to a different page. Screen reader users have no idea the page changed.
This is the accessibility gap that most React tutorials ignore, and it's the one I've spent the most time solving.
Here's my approach: after every route change, move focus to the main content area and announce the new page to screen readers.
"use client";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
function RouteAnnouncer() {
const pathname = usePathname();
const announcerRef = useRef<HTMLDivElement>(null);
const previousPathname = useRef(pathname);
useEffect(() => {
if (previousPathname.current === pathname) return;
previousPathname.current = pathname;
// Move focus to main content
const mainContent = document.getElementById("main-content");
if (mainContent) {
mainContent.focus({ preventScroll: false });
}
// Announce route change to screen readers
if (announcerRef.current) {
const pageTitle = document.title || "Page";
announcerRef.current.textContent = `Navigated to ${pageTitle}`;
}
}, [pathname]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
);
}Next.js 14+ includes a built-in route announcer, but I've found it inconsistent across screen readers. The component above gives me full control over the announcement text and focus target.
Focus trapping is equally important for modal dialogs. When a modal opens, focus must be confined inside it — tabbing past the last element should cycle back to the first, not escape into the page behind. Here's my production pattern:
"use client";
import { useEffect, useRef, useCallback } from "react";
function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (!isActive || event.key !== "Tab") return;
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
},
[isActive]
);
useEffect(() => {
if (!isActive) return;
document.addEventListener("keydown", handleKeyDown);
// Focus the first focusable element when trap activates
const container = containerRef.current;
if (container) {
const firstFocusable = container.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isActive, handleKeyDown]);
return containerRef;
}I use this hook in every modal, drawer, and dropdown menu. When the overlay closes, I restore focus to the element that triggered it — otherwise keyboard users lose their place on the page.
Color Contrast -- 4.5:1 for Text
WCAG 2.1 AA requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text (18px bold or 24px regular). This sounds simple until you're working with a dark theme, gradients, or semi-transparent overlays.
My brand system uses #FFFFFF text on #0A0E1A background — that's a ratio of 18.3:1, well above the minimum. But the secondary text color #6B7FA3 on #0A0E1A is where things get tight. Let me check: that combination yields a ratio of approximately 4.8:1, which passes AA but barely. For text smaller than 14px, I bump it to #C9D1E0 (8.7:1 ratio) to give comfortable breathing room.
Here's how I define accessible color tokens in Tailwind v4:
@theme {
/* Text colors with contrast ratios documented */
--color-text-primary: #ffffff; /* 18.3:1 on --color-bg */
--color-text-secondary: #c9d1e0; /* 8.7:1 on --color-bg */
--color-text-muted: #6b7fa3; /* 4.8:1 on --color-bg — large text only */
--color-text-disabled: #4a5568; /* 3.2:1 on --color-bg — meets 3:1 for UI */
--color-bg: #0a0e1a;
--color-surface: #111827;
--color-elevated: #1a2236;
}Documenting contrast ratios in comments is a habit I picked up after spending an hour debugging why an accessibility audit flagged a color that "looked fine." The numbers don't lie, and your eyes adjust to your monitor — the contrast checker doesn't.
For interactive elements, the contrast requirements extend to focus indicators, borders, and state changes. A button's disabled state still needs 3:1 contrast for the text. An error message's red needs 4.5:1 against its background. Here's my utility for checking at build time:
// lib/contrast.ts
function getLuminance(hex: string): number {
const rgb = hex
.replace("#", "")
.match(/.{2}/g)!
.map((c) => {
const v = parseInt(c, 16) / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
export function getContrastRatio(fg: string, bg: string): number {
const l1 = getLuminance(fg);
const l2 = getLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
export function meetsWCAG(
fg: string,
bg: string,
level: "AA" | "AAA" = "AA",
isLargeText: boolean = false
): boolean {
const ratio = getContrastRatio(fg, bg);
if (level === "AAA") return isLargeText ? ratio >= 4.5 : ratio >= 7;
return isLargeText ? ratio >= 3 : ratio >= 4.5;
}I run this in tests to catch regressions when someone changes a color token. Automated enforcement beats manual audits every time.
ARIA Labels and Roles
ARIA (Accessible Rich Internet Applications) is powerful and dangerous. Used correctly, it bridges gaps where semantic HTML falls short. Used incorrectly, it creates a worse experience than no ARIA at all. The first rule of ARIA is: don't use ARIA if a native HTML element does the job.
Here are the ARIA patterns I use most often in production:
Labels for icon buttons — An icon button with no visible text needs aria-label:
<button
onClick={onClose}
aria-label="Close dialog"
className="rounded-md p-2 hover:bg-gray-800 focus-visible:outline-2 focus-visible:outline-orange-500"
>
<XIcon className="h-5 w-5" aria-hidden="true" />
</button>The icon is decorative (the label provides the name), so it gets aria-hidden="true".
Live regions for dynamic content — When content updates without a page reload (notifications, form validation, loading states), screen readers won't announce it unless you tell them to:
function StatusMessage({ message, type }: { message: string; type: "success" | "error" }) {
return (
<div
role={type === "error" ? "alert" : "status"}
aria-live={type === "error" ? "assertive" : "polite"}
className={`rounded-md p-3 ${
type === "error" ? "bg-red-900/30 text-red-400" : "bg-green-900/30 text-green-400"
}`}
>
{message}
</div>
);
}Errors use role="alert" with aria-live="assertive" because they demand immediate attention. Success messages use role="status" with aria-live="polite" — they wait for the screen reader to finish its current announcement.
Describing relationships — When a button controls a collapsible section, aria-expanded and aria-controls communicate the relationship:
function Accordion({
title,
children,
id,
}: {
title: string;
children: React.ReactNode;
id: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const panelId = `${id}-panel`;
return (
<div>
<h3>
<button
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen(!isOpen)}
className="flex w-full items-center justify-between py-3 text-left font-medium focus-visible:outline-2 focus-visible:outline-orange-500"
>
{title}
<ChevronIcon
className={`h-5 w-5 transition-transform ${isOpen ? "rotate-180" : ""}`}
aria-hidden="true"
/>
</button>
</h3>
<div id={panelId} role="region" aria-labelledby={id} hidden={!isOpen}>
{children}
</div>
</div>
);
}One mistake I see constantly: using aria-label on elements that already have visible text. If a button says "Submit Order," adding aria-label="Submit the order" creates a mismatch — screen reader users hear something different from what sighted users see. Use aria-label only when there is no visible label. Use aria-describedby to add supplementary information.
Screen Reader Testing
Automated tools like axe-core and Lighthouse catch roughly 30-40% of accessibility issues. The rest — reading order, announcement clarity, interaction flow — can only be verified by actually using a screen reader.
Here's my testing workflow:
Tools I use:
- NVDA (Windows, free) — Primary testing. Most popular screen reader globally.
- VoiceOver (macOS/iOS, built-in) — Essential for Safari and mobile testing.
- axe DevTools (browser extension) — Automated scanning as a first pass.
- Accessibility Insights (Microsoft, free) — Tab stop visualization and FastPass.
What I check manually:
- Navigate the entire page using only Tab and Shift+Tab. Can I reach everything? Can I get back?
- Activate every interactive element with Enter and Space. Do they work?
- Open and close every modal, dropdown, and drawer. Does focus trap correctly? Does focus restore on close?
- Read through the page with NVDA. Does the heading structure make sense? Are images described? Are form fields labeled?
- Fill out and submit every form using keyboard only. Are errors announced? Is the error location clear?
Automating the first pass with axe-core in tests:
// __tests__/accessibility.test.tsx
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { ContactForm } from "@/components/contact-form";
expect.extend(toHaveNoViolations);
describe("ContactForm accessibility", () => {
it("has no axe violations", async () => {
const { container } = render(<ContactForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});I run axe tests on every component in CI. They don't catch everything, but they catch regressions — and that's worth the thirty seconds of build time. For client projects where I'm building complex interfaces, I supplement automated tests with a manual screen reader pass on every user-facing flow before launch.
Accessible Forms
Forms are where accessibility fails hardest. Missing labels, error messages that appear visually but aren't announced, required fields indicated only by color, validation that clears the form on failure — I've seen all of it in production codebases.
Here's the form pattern I've standardized across every project:
"use client";
import { useId, useState } from "react";
interface FormFieldProps {
label: string;
name: string;
type?: string;
required?: boolean;
error?: string;
helpText?: string;
}
function FormField({
label,
name,
type = "text",
required = false,
error,
helpText,
}: FormFieldProps) {
const id = useId();
const errorId = `${id}-error`;
const helpId = `${id}-help`;
const describedBy = [
error ? errorId : null,
helpText ? helpId : null,
]
.filter(Boolean)
.join(" ");
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-white">
{label}
{required && (
<span aria-hidden="true" className="ml-1 text-red-400">
*
</span>
)}
{required && <span className="sr-only">(required)</span>}
</label>
<input
id={id}
name={name}
type={type}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={describedBy || undefined}
className={`w-full rounded-md border bg-gray-900 px-3 py-2 text-white placeholder:text-gray-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-500 ${
error
? "border-red-500 focus-visible:outline-red-500"
: "border-gray-700"
}`}
/>
{helpText && (
<p id={helpId} className="text-sm text-gray-400">
{helpText}
</p>
)}
{error && (
<p id={errorId} role="alert" className="text-sm text-red-400">
{error}
</p>
)}
</div>
);
}Key details that make this work:
- `useId()` generates unique, stable IDs for the
htmlFor/idconnection. Never rely onnamealone — it's not guaranteed unique on a page with multiple forms. - `aria-required` is set alongside the HTML
requiredattribute for belt-and-suspenders coverage. - `aria-invalid` tells screen readers the field has a validation error. They announce "invalid entry" — without this, users hear nothing.
- `aria-describedby` links help text and error messages to the input. Screen readers announce these when the field receives focus.
- The asterisk uses
aria-hidden="true"with asr-onlytext alternative. Screen readers say "required" instead of reading the asterisk character. - Errors use
role="alert"so they're announced immediately when they appear, not just when the user tabs back to the field.
For form-level error summaries after submission, I focus the summary and list each error with a link to the offending field:
function ErrorSummary({
errors,
}: {
errors: Array<{ field: string; message: string }>;
}) {
const summaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (errors.length > 0) {
summaryRef.current?.focus();
}
}, [errors]);
if (errors.length === 0) return null;
return (
<div
ref={summaryRef}
role="alert"
tabIndex={-1}
className="rounded-md border border-red-500/30 bg-red-900/20 p-4"
>
<h2 className="text-sm font-semibold text-red-400">
There {errors.length === 1 ? "is 1 error" : `are ${errors.length} errors`} in this form
</h2>
<ul className="mt-2 space-y-1">
{errors.map(({ field, message }) => (
<li key={field}>
<a
href={`#${field}`}
className="text-sm text-red-300 underline hover:text-red-200"
>
{message}
</a>
</li>
))}
</ul>
</div>
);
}This pattern — focus the summary, list the errors, link each to its field — is the GOV.UK design system approach, and it's the most usable form error pattern I've encountered. I've adopted it across every project.
My Accessibility Checklist
After three years of building accessible interfaces, I've distilled my process into a checklist I run on every project before launch. This isn't theoretical — it's what I actually check, in order, on every client build:
Structure:
- [ ] Page has exactly one
<h1>, heading levels don't skip - [ ] Landmarks are present:
<header>,<nav>,<main>,<footer> - [ ] Skip link is the first focusable element and works correctly
- [ ] Language is declared on
<html lang="..."> - [ ] Page title is unique and descriptive for every route
Images and media:
- [ ] Every
<img>has analtattribute (emptyalt=""for decorative images) - [ ] Complex images (charts, infographics) have extended descriptions
- [ ] Video has captions; audio has transcripts
- [ ] Animations respect
prefers-reduced-motion
Keyboard:
- [ ] All interactive elements are reachable via Tab
- [ ] Focus order follows visual reading order
- [ ] Focus indicators are visible (minimum 3:1 contrast)
- [ ] No keyboard traps — Escape dismisses overlays, Tab cycles naturally
- [ ] Custom widgets (tabs, menus, accordions) follow WAI-ARIA Authoring Practices
Color and contrast:
- [ ] Body text meets 4.5:1 contrast ratio
- [ ] Large text meets 3:1 contrast ratio
- [ ] UI components (borders, icons) meet 3:1 against adjacent colors
- [ ] Information is not conveyed by color alone (use icons, text, patterns)
- [ ] Dark mode maintains all contrast requirements
Forms:
- [ ] Every input has a visible, associated
<label> - [ ] Required fields are indicated to all users (not just visually)
- [ ] Error messages are associated with fields via
aria-describedby - [ ] Error messages use
role="alert"for immediate announcement - [ ] Form submissions preserve entered data on validation failure
Dynamic content:
- [ ] Route changes announce the new page title
- [ ] Loading states are announced (aria-live region or aria-busy)
- [ ] Modals trap focus and restore it on close
- [ ] Toast notifications use appropriate
aria-livepoliteness - [ ] Content added to the DOM is announced or focusable
Testing:
- [ ] axe-core reports zero violations
- [ ] Lighthouse Accessibility score is 95+
- [ ] Full keyboard-only navigation pass completed
- [ ] Screen reader pass with NVDA or VoiceOver on all key flows
- [ ] Tested at 200% zoom with no content loss
I keep this checklist as a GitHub issue template. Every project gets one, and every item gets checked off with a comment noting how it was verified. No item gets marked as "N/A" without justification.
Key Takeaways
- Accessibility is a quality attribute, not a feature. Build with it from the first component. Retrofitting is always more expensive.
- Semantic HTML is your highest-leverage tool.
<button>,<nav>,<main>,<label>— these give you keyboard support, screen reader announcements, and proper focus management for free.
- Focus management is the hardest problem in SPA accessibility. Route changes, modals, and dynamic content all require explicit focus handling that server-rendered pages get for free.
- 4.5:1 contrast ratio for text is non-negotiable. Document your ratios in code comments. Test them automatically. Don't trust your eyes — trust the numbers.
- ARIA is a supplement, not a substitute. The first rule of ARIA is don't use ARIA when native HTML works. When you do use it,
aria-label,aria-describedby,aria-live, andaria-expandedcover 90% of cases.
- Automated testing catches 30-40% of issues. Run axe-core in CI for regression detection, but always complete a manual keyboard and screen reader pass on user-facing flows.
- Accessible forms require four things: visible labels linked with
htmlFor,aria-invalidon error,aria-describedbylinking to error messages, androle="alert"on errors for immediate announcement.
- Respect user preferences.
prefers-reduced-motion,prefers-color-scheme, andprefers-contrastare not optional media queries — they're user requests.
Accessibility is not a checklist exercise you complete to avoid lawsuits. It's a commitment to building interfaces that work for everyone — the user navigating with a keyboard because of a motor impairment, the user on a screen reader because they're blind, the user in bright sunlight who needs high contrast, and the power user who navigates everything with keyboard shortcuts because it's faster. When you build for the edges, the center gets better too.
If you're building a project that needs to meet WCAG standards from day one, that's exactly how I approach every client engagement. Accessibility is baked into my process, not bolted on at the end.
*Written by Uvin Vindula↗ — Web3/AI engineer building production-grade applications from Sri Lanka and the UK. Accessibility advocate. Every project ships with WCAG 2.1 AA as the minimum bar. Follow my work at @IAMUVIN↗ or explore what I build↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.