Web Accessibility
Web accessibility (a11y) ensures that people with disabilities can perceive, understand, navigate, and interact with the web. It is not a feature — it is a fundamental requirement of the web. Approximately 15% of the global population has some form of disability, and inaccessible design excludes them from digital experiences.
The WCAG (Web Content Accessibility Guidelines) define three conformance levels: A (minimum), AA (standard — legal requirement in most regions), and AAA (advanced). The four POUR principles — Perceivable, Operable, Understandable, Robust — form the foundation of all accessibility work.
Semantic HTML is the single most impactful accessibility technique. Screen readers and assistive technologies rely on element semantics to convey meaning and structure to users. A well-structured document with semantic elements often requires zero ARIA.
Landmark regions help screen reader users navigate the page quickly. Browsers and assistive technologies expose these landmarks for shortcut navigation:
| Landmark | HTML Element | Purpose |
|---|---|---|
| Banner | <header> | Site-oriented content (logo, search) |
| Navigation | <nav> | Navigation links |
| Main | <main> | Primary content (one per page) |
| Complementary | <aside> | Supporting content |
| Content Info | <footer> | Copyright, privacy links |
| Region | <section> | Topic with heading |
| Form | <form> | Collection of form controls |
| Search | <form role="search"> | Search functionality |
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8" /> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <title>Accessible Page</title> |
| 7 | </head> |
| 8 | <body> |
| 9 | <header> |
| 10 | <nav aria-label="Main navigation"> |
| 11 | <ul> |
| 12 | <li><a href="/">Home</a></li> |
| 13 | <li><a href="/products">Products</a></li> |
| 14 | <li><a href="/about">About</a></li> |
| 15 | <li><a href="/contact">Contact</a></li> |
| 16 | </ul> |
| 17 | </nav> |
| 18 | </header> |
| 19 | |
| 20 | <main> |
| 21 | <h1>Welcome to Our Site</h1> |
| 22 | <p>This page uses semantic landmarks for accessibility.</p> |
| 23 | |
| 24 | <section aria-labelledby="features-heading"> |
| 25 | <h2 id="features-heading">Features</h2> |
| 26 | <ul> |
| 27 | <li>Fast and reliable</li> |
| 28 | <li>Accessible by design</li> |
| 29 | <li>Open source</li> |
| 30 | </ul> |
| 31 | </section> |
| 32 | </main> |
| 33 | |
| 34 | <footer> |
| 35 | <p>© 2026 Accessible Corp</p> |
| 36 | </footer> |
| 37 | </body> |
| 38 | </html> |
best practice
ARIA is a specification that defines attributes to enhance accessibility when native HTML semantics are insufficient. It provides roles, states, and properties that tell assistive technologies about element behavior and current state.
Roles describe the type of widget or structure an element represents. The main categories are widget, structure, landmark, and live region roles.
| Category | Roles | Usage |
|---|---|---|
| Widget | button, link, checkbox, radio, slider, tab, menuitem | Interactive UI components |
| Structure | heading, list, listitem, table, row, cell, img | Document structure elements |
| Landmark | banner, navigation, main, complementary, contentinfo, form, search, region | Page regions for navigation |
| Live Region | alert, log, marquee, status, timer | Dynamic content updates |
ARIA States & Properties
ARIA attributes describe the current state of an element or provide additional context. They begin with aria-:
| Attribute | Values | Description |
|---|---|---|
| aria-expanded | true, false, undefined | Indicates if a collapsible element is open |
| aria-controls | ID reference | References the element being controlled |
| aria-label | string | Provides an accessible name |
| aria-labelledby | ID reference | References element that labels this one |
| aria-describedby | ID reference | References element with a description |
| aria-hidden | true, false | Hides element from accessibility tree |
| aria-current | page, step, location, date, time, true, false | Indicates the current item in a set |
| aria-live | off, polite, assertive | Politeness of dynamic content announcements |
| aria-atomic | true, false | Announce region as whole or by changes |
| aria-relevant | additions, removals, text, all | What types of changes are relevant |
| aria-busy | true, false | Element is being updated |
| aria-required | true, false | Indicates required form field |
| aria-invalid | true, false, grammar, spelling | Indicates input validation error |
| aria-disabled | true, false | Element is disabled but still visible |
| 1 | <!-- Tab widget with ARIA --> |
| 2 | <div role="tablist" aria-label="Documentation tabs"> |
| 3 | <button role="tab" aria-selected="true" aria-controls="panel-1" |
| 4 | id="tab-1" tabindex="0">HTML</button> |
| 5 | <button role="tab" aria-selected="false" aria-controls="panel-2" |
| 6 | id="tab-2" tabindex="-1">CSS</button> |
| 7 | <button role="tab" aria-selected="false" aria-controls="panel-3" |
| 8 | id="tab-3" tabindex="-1">JS</button> |
| 9 | </div> |
| 10 | |
| 11 | <div role="tabpanel" id="panel-1" aria-labelledby="tab-1"> |
| 12 | <p>HTML content here.</p> |
| 13 | </div> |
| 14 | <div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden> |
| 15 | <p>CSS content here.</p> |
| 16 | </div> |
| 17 | <div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden> |
| 18 | <p>JS content here.</p> |
| 19 | </div> |
| 20 | |
| 21 | <!-- Current page in navigation --> |
| 22 | <nav aria-label="Breadcrumb"> |
| 23 | <ol> |
| 24 | <li><a href="/">Home</a></li> |
| 25 | <li><a href="/docs">Docs</a></li> |
| 26 | <li><span aria-current="page">Accessibility</span></li> |
| 27 | </ol> |
| 28 | </nav> |
| 29 | |
| 30 | <!-- Live region for notifications --> |
| 31 | <div aria-live="polite" aria-atomic="true" class="notification"> |
| 32 | </div> |
The first rule of ARIA is: do not use ARIA if a native HTML element exists that provides the semantics and behavior you need. Native elements have built-in keyboard handling, focus management, and accessibility mappings that ARIA cannot fully replicate.
| 1 | <!-- BAD: div with ARIA button --> |
| 2 | <div role="button" tabindex="0" onclick="submit()"> |
| 3 | Submit |
| 4 | </div> |
| 5 | <!-- Problems: no Enter key handling in some browsers, no native form submission, |
| 6 | no default button styling, requires manual focus management --> |
| 7 | |
| 8 | <!-- GOOD: native button --> |
| 9 | <button type="button" onclick="submit()">Submit</button> |
| 10 | <!-- Built-in: click, keyboard, focus, form behavior, accessibility --> |
The second rule: no ARIA is better than bad ARIA. Incorrect ARIA can actively harm accessibility by providing misleading information to screen readers.
| 1 | <!-- BAD: incorrect ARIA that breaks expectations --> |
| 2 | <div role="heading"> |
| 3 | This looks like a heading but lacks proper level |
| 4 | </div> |
| 5 | <!-- Screen reader announces "heading" but cannot navigate by level --> |
| 6 | |
| 7 | <!-- BAD: redundant ARIA --> |
| 8 | <button role="button">Click Me</button> |
| 9 | <!-- Native button already has role="button" -- redundant --> |
| 10 | |
| 11 | <!-- BAD: missing keyboard support --> |
| 12 | <div role="checkbox" aria-checked="false" onclick="toggle()"> |
| 13 | Accept terms |
| 14 | </div> |
| 15 | <!-- Space key must also toggle the checkbox --> |
| 16 | <!-- Use native <input type="checkbox"> instead --> |
warning
Screen readers (JAWS, NVDA, VoiceOver, TalkBack) convert digital text to synthesized speech or braille. They navigate by traversing the accessibility tree, which is derived from the DOM but augmented with ARIA.
Key practices for screen reader compatibility:
| 1 | <!-- alt text — most important SR practice --> |
| 2 | <!-- Informative image: describe the content --> |
| 3 | <img src="chart.png" |
| 4 | alt="Bar chart showing Q1 sales: Jan $5K, Feb $7K, Mar $6K" /> |
| 5 | |
| 6 | <!-- Decorative image: hide from screen readers --> |
| 7 | <img src="divider.svg" alt="" role="presentation" /> |
| 8 | <!-- Or use: alt="" (empty alt = hidden from SR) --> |
| 9 | |
| 10 | <!-- Link text must make sense out of context --> |
| 11 | <!-- BAD --> |
| 12 | <a href="/docs/guide">click here</a> for the guide. |
| 13 | |
| 14 | <!-- GOOD --> |
| 15 | <a href="/docs/guide">Read the full documentation guide</a>. |
| 16 | |
| 17 | <!-- Heading hierarchy — never skip levels --> |
| 18 | <h1>Page Title</h1> |
| 19 | <h2>Section</h2> |
| 20 | <h3>Subsection</h3> |
| 21 | <h2>Another Section</h2> |
| 22 | |
| 23 | <!-- Use aria-label for icon-only buttons --> |
| 24 | <button aria-label="Close dialog"> |
| 25 | <svg aria-hidden="true" focusable="false">...</svg> |
| 26 | </button> |
| 27 | |
| 28 | <!-- Hidden headings for structure --> |
| 29 | <section aria-labelledby="section-heading"> |
| 30 | <h2 id="section-heading" class="sr-only">Contact Form</h2> |
| 31 | <!-- form content --> |
| 32 | </section> |
WCAG 2.1 requires minimum contrast ratios for text and UI components. Insufficient contrast is one of the most common accessibility failures on the web.
| Level | Normal Text | Large Text (18px+ bold or 24px+) | UI Components |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | — |
| 1 | /* WCAG AA compliant color combinations */ |
| 2 | .text-primary { |
| 3 | color: #00FF41; /* on #0D0D0D = 11.5:1 ✓ AAA */ |
| 4 | background: #0D0D0D; |
| 5 | } |
| 6 | |
| 7 | .text-body { |
| 8 | color: #A0A0A0; /* on #0D0D0D = 7.2:1 ✓ AAA */ |
| 9 | background: #0D0D0D; |
| 10 | } |
| 11 | |
| 12 | .text-muted { |
| 13 | color: #808080; /* on #0D0D0D = 4.7:1 ✓ AA */ |
| 14 | background: #0D0D0D; |
| 15 | } |
| 16 | |
| 17 | .text-disabled { |
| 18 | color: #525252; /* on #0D0D0D = 3.1:1 ✗ FAIL */ |
| 19 | background: #0D0D0D; |
| 20 | /* Only use for decorative text — not for interactive or important content */ |
| 21 | } |
| 22 | |
| 23 | /* Don't rely on color alone — use icons and text */ |
| 24 | .error-message { |
| 25 | color: #FF6B6B; |
| 26 | } |
| 27 | .error-message::before { |
| 28 | content: "⚠ "; /* Adds warning symbol beside color */ |
| 29 | } |
best practice
Accessible forms are critical for users of all abilities. Every form control must have a programmatically associated label, clear error messaging, and appropriate ARIA states.
| 1 | <!-- Every input needs a label --> |
| 2 | <label for="email">Email Address</label> |
| 3 | <input type="email" id="email" name="email" |
| 4 | required autocomplete="email" |
| 5 | aria-describedby="email-hint" /> |
| 6 | |
| 7 | <p id="email-hint" class="hint"> |
| 8 | We'll never share your email. |
| 9 | </p> |
| 10 | |
| 11 | <!-- Required fields --> |
| 12 | <label for="name">Full Name <span aria-hidden="true">*</span></label> |
| 13 | <input type="text" id="name" name="name" required |
| 14 | aria-required="true" /> |
| 15 | |
| 16 | <!-- Error state with validation --> |
| 17 | <label for="password">Password</label> |
| 18 | <input |
| 19 | type="password" |
| 20 | id="password" |
| 21 | name="password" |
| 22 | required |
| 23 | minlength="8" |
| 24 | aria-invalid="true" |
| 25 | aria-describedby="password-error" |
| 26 | /> |
| 27 | <span id="password-error" role="alert"> |
| 28 | Password must be at least 8 characters. |
| 29 | </span> |
| 30 | |
| 31 | <!-- Grouped form controls --> |
| 32 | <fieldset> |
| 33 | <legend>Shipping Address</legend> |
| 34 | <label for="street">Street</label> |
| 35 | <input type="text" id="street" name="street" /> |
| 36 | <label for="city">City</label> |
| 37 | <input type="text" id="city" name="city" /> |
| 38 | </fieldset> |
| 39 | |
| 40 | <!-- Custom checkbox with proper pattern --> |
| 41 | <label class="checkbox-label"> |
| 42 | <input type="checkbox" checked /> |
| 43 | <span>I agree to the terms and conditions</span> |
| 44 | </label> |
info
Dynamic content updates — notifications, chat messages, loading states, form errors — must be announced to screen reader users. The aria-live region is the mechanism for this.
| Attribute | Value | Behavior |
|---|---|---|
| aria-live | polite | Announces when idle (for most updates) |
| aria-live | assertive | Interrupts current speech (use sparingly) |
| role | alert | Equivalent to aria-live="assertive" |
| role | status | Equivalent to aria-live="polite" |
| aria-atomic | true | Announce entire region, not just changed parts |
| 1 | <!-- Toast notification — announces content --> |
| 2 | <div role="alert" aria-live="assertive" class="toast"> |
| 3 | Your changes have been saved. |
| 4 | </div> |
| 5 | |
| 6 | <!-- Chat messages — polite updates --> |
| 7 | <div |
| 8 | aria-live="polite" |
| 9 | aria-atomic="false" |
| 10 | aria-relevant="additions text" |
| 11 | class="chat-messages" |
| 12 | > |
| 13 | <!-- new messages appear here --> |
| 14 | </div> |
| 15 | |
| 16 | <!-- Loading spinner with aria-live --> |
| 17 | <div role="status" aria-live="polite"> |
| 18 | <span class="spinner" aria-hidden="true"></span> |
| 19 | Loading search results... |
| 20 | </div> |
| 21 | |
| 22 | <!-- JavaScript-driven announcements --> |
| 23 | <script> |
| 24 | function announce(message) { |
| 25 | const announcer = document.getElementById("announcer"); |
| 26 | announcer.textContent = ""; // Clear first |
| 27 | // Use setTimeout to ensure change is detected |
| 28 | setTimeout(() => { |
| 29 | announcer.textContent = message; |
| 30 | }, 50); |
| 31 | } |
| 32 | </script> |
| 33 | <div id="announcer" aria-live="polite" aria-atomic="true" |
| 34 | class="sr-only"> |
| 35 | </div> |
pro tip
Accessibility testing combines automated tools with manual testing. Automated tools catch roughly 30% of all accessibility issues — the remaining 70% require human judgment.
Automated Tools
| Tool | Type | Best For |
|---|---|---|
| axe DevTools | Browser extension | Comprehensive automated audits |
| WAVE | Browser extension | Visual overlay of issues on the page |
| Lighthouse | Chrome DevTools | Performance + accessibility score |
| Pa11y | CLI tool | CI/CD pipeline integration |
| Accessibility Insights | Desktop app + extension | Guided manual testing workflows |
Manual Testing Checklist
| 1 | MANUAL ACCESSIBILITY TESTING CHECKLIST |
| 2 | |
| 3 | [ ] Keyboard Navigation |
| 4 | [ ] Tab through all interactive elements |
| 5 | [ ] All elements receive visible focus |
| 6 | [ ] Tab order follows visual order (no positive tabindex) |
| 7 | [ ] Enter/Space activates buttons and links |
| 8 | [ ] Arrow keys work for tabs, lists, menus |
| 9 | [ ] Escape closes dialogs and menus |
| 10 | [ ] No keyboard traps (focus gets stuck) |
| 11 | |
| 12 | [ ] Screen Reader |
| 13 | [ ] All images have appropriate alt text |
| 14 | [ ] Headings form a logical hierarchy (no skips) |
| 15 | [ ] Links make sense out of context |
| 16 | [ ] Forms have labels on all controls |
| 17 | [ ] Dynamic content is announced |
| 18 | [ ] ARIA landmarks are present |
| 19 | [ ] Custom widgets announce state changes |
| 20 | |
| 21 | [ ] Zoom & Scaling |
| 22 | [ ] Page is usable at 200% zoom |
| 23 | [ ] No horizontal scroll at 400% zoom |
| 24 | [ ] Text reflows without truncation |
| 25 | |
| 26 | [ ] Color & Contrast |
| 27 | [ ] All text meets 4.5:1 ratio (AA) |
| 28 | [ ] Information not conveyed by color alone |
| 29 | [ ] Focus indicators have sufficient contrast |
| 30 | [ ] Error states visible without relying on color |
| 31 | |
| 32 | [ ] Forms |
| 33 | [ ] All inputs have associated labels |
| 34 | [ ] Error messages linked via aria-describedby |
| 35 | [ ] Required fields indicated visually and programmatically |
| 36 | [ ] Autocomplete attributes on common fields |
info
best practice