Accessibility in Web Development
Try this on a site you've built: unplug the mouse and fill out the signup form with only the keyboard. On a lot of projects you'll get stuck within a minute. The custom dropdown won't open, focus vanishes into an invisible modal, or the "Submit" button is a <div> that Tab skips entirely. That's the everyday experience for people who navigate with a keyboard or a screen reader, and it's why accessibility (a11y) is part of the job, not a polish step. In many countries it's also a legal requirement.
The fixes help more people than you'd expect. Captions help someone watching in a noisy train, and good contrast helps anyone reading a phone in sunlight. This post covers the parts I find matter most in real projects: WCAG levels, semantic HTML, when ARIA helps (and when it hurts), keyboard and focus handling, forms, contrast, media, and how to test all of it.
- Understanding WCAG guidelines and conformance levels
- Writing semantic HTML for screen readers
- Implementing ARIA attributes correctly
- Creating accessible forms and error handling
- Keyboard navigation and focus management
- Color contrast and visual accessibility
- Testing tools and automated accessibility checks
- Common accessibility mistakes to avoid
Understanding Web Accessibility
The goal is simple to state: people with disabilities should be able to perceive, understand, navigate and interact with what you build. That covers a wide range of needs (auditory, cognitive, neurological, physical, speech and visual), and different users rely on very different tools. WCAG groups its requirements under four principles, usually shortened to POUR.
The four POUR principles that every WCAG success criterion falls under
WCAG Guidelines and Conformance Levels
When a client or a procurement form says "the site must be accessible", they almost always mean the Web Content Accessibility Guidelines (WCAG). The current version is 2.2, and it has three conformance levels: A, AA and AAA. Most laws and contracts point at Level AA (often still WCAG 2.1 AA), so that's the realistic target for most projects.
| Level | Description | Requirements | Legal Standard |
|---|---|---|---|
| Level A | Minimum accessibility | 30 success criteria | Basic compliance |
| Level AA | Acceptable accessibility | 20 additional criteria | Most laws require this |
| Level AAA | Optimal accessibility | 28 additional criteria | Aspirational goal |
Don't promise AAA across a whole site. Even the W3C says it isn't achievable for all content. Hit AA first, then pick up AAA criteria where they're cheap, like higher contrast on body text. Contrast and keyboard fixes alone clear a big chunk of the issues real users report.
Semantic HTML: The Foundation
Most of the accessibility you need comes free with the right HTML element. A <button> is focusable, responds to Enter and Space, and gets announced as a button. A <div> with a click handler gets none of that. Screen reader users also jump around a page by landmarks and headings, so <nav>, <main> and a sensible <h1> to <h3> outline do real work.
Common Semantic Elements
Landmark elements that screen readers expose for quick navigation
<!-- ✅ Semantic HTML - Accessible -->
<header>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<main>
<article>
<h1>Understanding Web Accessibility</h1>
<p>Web accessibility ensures everyone can use the web...</p>
<section>
<h2>Why It Matters</h2>
<p>Over 1 billion people have disabilities...</p>
</section>
</article>
<aside aria-label="Related content">
<h2>Related Articles</h2>
<ul>
<li><a href="/wcag">WCAG Guidelines</a></li>
</ul>
</aside>
</main>
<footer>
<p>© 2026 My Website</p>
</footer>
<!-- ❌ Non-Semantic HTML - Not Accessible -->
<div class="header">
<div class="nav">
<div class="nav-item">
<span onclick="goTo('/')">Home</span>
</div>
<div class="nav-item">
<span onclick="goTo('/about')">About</span>
</div>
</div>
</div>
<div class="main">
<div class="article">
<div class="title">Understanding Web Accessibility</div>
<div class="text">Web accessibility ensures...</div>
<div class="section">
<div class="subtitle">Why It Matters</div>
<div class="text">Over 1 billion people...</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar-title">Related Articles</div>
<div class="sidebar-item">
<span onclick="goTo('/wcag')">WCAG Guidelines</span>
</div>
</div>
</div>
<div class="footer">
<div>© 2026 My Website</div>
</div>
A page built from <div> and <span> can look identical, but a screen reader sees a flat wall of text. No headings, no landmarks, no buttons. The <span onclick> links in the bad example also can't be reached with Tab at all.
ARIA: Accessible Rich Internet Applications
Sooner or later you'll build something HTML has no element for: a toggle, an accordion, a tab panel, a live status message. ARIA attributes fill that gap by telling assistive tech what a widget is and what state it's in. They don't add any behavior, though, which is why the first rule of ARIA is don't use ARIA if a native HTML element does the job. Bad ARIA is worse than none, because it makes the screen reader announce things that aren't true.
ARIA Roles, States, and Properties
Roles
Define what an element is: role="button", role="alert", role="dialog"
States
Current condition: aria-expanded, aria-checked, aria-selected
Properties
Characteristics: aria-label, aria-describedby, aria-required
Live Regions
Dynamic updates: aria-live="polite", aria-live="assertive"
<!-- Custom button with ARIA -->
<div role="button"
tabindex="0"
aria-pressed="false"
onclick="toggleButton(this)"
onkeydown="handleKeyPress(event, this)">
Toggle Feature
</div>
<!-- Accordion with ARIA -->
<button aria-expanded="false"
aria-controls="panel1"
id="accordion1">
Section Title
</button>
<div id="panel1"
role="region"
aria-labelledby="accordion1"
hidden>
Panel content goes here...
</div>
<!-- Live region for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<!-- Screen readers announce changes here -->
</div>
<!-- Modal dialog -->
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-desc">Are you sure you want to proceed?</p>
<button>Confirm</button>
<button>Cancel</button>
</div>
Keyboard Navigation
People with motor disabilities, most blind users, and plenty of power users never touch a mouse. If something can be clicked, it has to be reachable with Tab and usable with Enter, Space or the arrow keys. Focus also has to stay visible and go somewhere sensible when a modal opens or closes.
How Tab moves through focusable elements, plus the keys users expect to work
One gotcha with the focus trap below: it grabs the focusable elements once, when the modal opens. If the modal's content changes later (a step-by-step form, or a button that's disabled until a field is valid), the cached first and last elements go stale and Tab escapes the dialog. Re-query inside the keydown handler if your modal content is dynamic. Also check that hidden elements aren't in the list, since focus() on a display:none element silently does nothing. Native <dialog> opened with showModal() handles most of this for you and is worth using if your browser support allows.
// Skip link for keyboard users
const skipLink = document.querySelector('.skip-link');
skipLink.addEventListener('click', (e) => {
e.preventDefault();
const main = document.querySelector('main');
main.tabIndex = -1;
main.focus();
});
// Focus trap for modals
function trapFocus(element) {
const focusableElements = element.querySelectorAll(
'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
element.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
if (e.key === 'Escape') {
closeModal();
}
});
firstElement.focus();
}
// Custom keyboard handler for interactive elements
function handleKeyboard(event, callback) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback();
}
}
Accessible Forms
Forms are where most of the real failures happen, because they're where users actually have to get something done. Someone using a screen reader needs to hear what each field is, know which ones are required, and find out what went wrong without hunting for a red border they can't see.
<form novalidate>
<!-- Properly labeled input -->
<div class="form-group">
<label for="email">
Email Address
<span aria-hidden="true">*</span>
</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-describedby="email-hint email-error"
autocomplete="email"
>
<span id="email-hint" class="hint">
We'll never share your email
</span>
<span id="email-error" class="error" role="alert" hidden>
Please enter a valid email address
</span>
</div>
<!-- Radio group with fieldset -->
<fieldset>
<legend>Preferred Contact Method</legend>
<div>
<input type="radio" id="contact-email" name="contact" value="email">
<label for="contact-email">Email</label>
</div>
<div>
<input type="radio" id="contact-phone" name="contact" value="phone">
<label for="contact-phone">Phone</label>
</div>
</fieldset>
<!-- Accessible error summary -->
<div role="alert" aria-live="assertive" id="error-summary" hidden>
<h2>There were errors with your submission</h2>
<ul>
<!-- Errors listed here with links to fields -->
</ul>
</div>
<button type="submit">Submit Form</button>
</form>
Form Accessibility Checklist
<label> with matching for/id
aria-required)
role="alert" and are linked via aria-describedby
<fieldset> and <legend> for grouped inputs
autocomplete attributes for common fields
Color Contrast and Visual Design
Light grey text on a white background looks clean in a Figma file and is unreadable for a lot of people with low vision, or anyone on a cheap laptop screen. WCAG sets minimum contrast ratios between text and background, and they're easy to check, so this is one of the quickest things to fix.
Contrast Examples
This text passes WCAG AA
This text fails WCAG
Images and Media Accessibility
Anything that isn't text (images, video, audio) needs a text alternative. What that alternative looks like depends on why the content is there. Ask yourself: if the image failed to load, what would the user miss?
Informative Images
Images that convey information need descriptive alt text.
<!-- Good: Descriptive alt text -->
<img
src="chart-sales-q4.png"
alt="Bar chart showing Q4 sales: Product A $50k, Product B $75k, Product C $30k"
>
<!-- Good: Action-oriented for functional images -->
<img
src="search-icon.png"
alt="Search"
>
<!-- Bad: Non-descriptive -->
<img src="chart.png" alt="chart">
<img src="image1.jpg" alt="image">
Decorative Images
Images that don't add information should be hidden from screen readers.
<!-- Option 1: Empty alt (recommended) -->
<img src="decorative-border.png" alt="">
<!-- Option 2: Using role -->
<img src="background-pattern.png" alt="" role="presentation">
<!-- Option 3: CSS background (best for decoration) -->
<div class="hero" style="background-image: url('hero-bg.jpg')">
<h1>Welcome</h1>
</div>
Complex Images
Charts, diagrams, and infographics need extended descriptions.
<figure>
<img
src="org-chart.png"
alt="Company organizational chart"
aria-describedby="org-chart-desc"
>
<figcaption>
Company Structure as of 2026
</figcaption>
</figure>
<div id="org-chart-desc" class="sr-only">
The CEO reports to the Board of Directors.
Three VPs report to the CEO: VP of Engineering (manages 50 developers),
VP of Sales (manages 30 sales staff), and VP of Marketing (manages 15 marketers).
</div>
Video Accessibility
Videos need captions, transcripts, and audio descriptions.
<video controls>
<source src="tutorial.mp4" type="video/mp4">
<!-- Captions for deaf/hard of hearing -->
<track
kind="captions"
src="captions-en.vtt"
srclang="en"
label="English"
default
>
<!-- Audio descriptions for blind users -->
<track
kind="descriptions"
src="descriptions-en.vtt"
srclang="en"
label="English Audio Description"
>
Your browser doesn't support video.
<a href="tutorial-transcript.html">Read transcript</a>
</video>
<!-- Link to full transcript -->
<p><a href="tutorial-transcript.html">Full video transcript</a></p>
Testing for Accessibility
An audit the week before launch usually finds problems baked into shared components, and by then they're expensive to fix. I'd rather catch them in CI and in code review. You'll still need manual checks, though, because tools can't tell whether your alt text makes sense.
Automated Testing Tools
Automated tools only catch a portion of accessibility issues (commonly cited estimates put it somewhere around a third). A clean Lighthouse score doesn't mean the site is accessible. Use them as a first pass.
- axe DevTools: browser extension, and the engine behind many other tools
- WAVE: draws its findings right on the page
- Lighthouse: built into Chrome DevTools
- eslint-plugin-jsx-a11y: lint rules for React/JSX
- Pa11y: easy to run in a CI pipeline
// Automated testing with Jest and axe-core
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Accessibility Tests', () => {
it('should have no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// Cypress with axe-core
describe('Page Accessibility', () => {
beforeEach(() => {
cy.visit('/');
cy.injectAxe();
});
it('Has no detectable a11y violations', () => {
cy.checkA11y();
});
it('Checks specific element', () => {
cy.checkA11y('.main-content', {
rules: {
'color-contrast': { enabled: true }
}
});
});
});
// Pa11y in CI/CD
// package.json script
{
"scripts": {
"test:a11y": "pa11y-ci --config .pa11yci.json"
}
}
// .pa11yci.json
{
"defaults": {
"standard": "WCAG2AA",
"timeout": 5000
},
"urls": [
"http://localhost:3000/",
"http://localhost:3000/about",
"http://localhost:3000/contact"
]
}
Manual Testing Checklist
Keyboard Navigation
Navigate your entire site using only Tab, Shift+Tab, Enter, Space, and Arrow keys. Every interactive element should be reachable and usable.
Screen Reader Testing
Test with NVDA (Windows, free), VoiceOver (Mac/iOS, built-in), or JAWS. Listen to how your content is announced.
Zoom and Magnification
Zoom to 200% and 400%. Content should reflow without horizontal scrolling. No information should be cut off.
Color and Contrast
Test with color blindness simulators. View in grayscale. Check contrast ratios with tools like the WebAIM Contrast Checker.
Common Accessibility Mistakes
❌ Missing or Poor Alt Text
Problem: Images without alt text, or with useless alt like "image" or the filename.
Solution: Write descriptive alt text that conveys the same information as the image. Use empty alt for decorative images.
❌ Removing Focus Outlines
Problem: Using outline: none or :focus { outline: 0 } makes keyboard navigation impossible.
Solution: If you don't like the default outline, replace it with a custom visible focus indicator. Use :focus-visible to only show focus for keyboard users.
❌ Inaccessible Custom Components
Problem: Building custom dropdowns, modals, or tabs without proper ARIA, keyboard support, or focus management.
Solution: Use established accessible component libraries, or follow WAI-ARIA Authoring Practices. Test with screen readers.
❌ Auto-Playing Media
Problem: Videos or audio that play automatically can disorient users, interfere with screen readers, and cause distress.
Solution: Never auto-play media with sound. If video must auto-play, keep it muted with easy controls to pause/stop.
In many places this is a legal obligation too, not only good practice:
- ADA (USA): courts have applied it to websites of businesses open to the public
- Section 508 (USA): covers federal agencies and what they buy
- EAA (EU): the European Accessibility Act covers many digital products and services
- AODA (Canada): Ontario's accessibility requirements
Ignoring it can lead to lawsuits and fines, and demand letters for inaccessible sites are common in the US.
What to Check Before Going to Production
You don't need to fix everything at once. If I only had an afternoon before a launch, this is the order I'd go in, because these items affect the most users for the least effort:
Pre-launch Checks
- Tab through the main flows (signup, checkout, contact). Can you finish them, and can you always see where focus is?
- Every form input has a real
<label>, not just a placeholder - No global
outline: noneleft in the CSS. Style the focus ring instead of removing it - Clickable things are
<button>or<a href>, not<div> - Body text meets 4.5:1 contrast, including placeholder and disabled-looking text
- Informative images have useful
alt, decorative ones havealt="" - A skip-to-content link exists and actually moves focus
- axe or Lighthouse shows zero critical issues on the key pages
- Videos have captions
- Five minutes with VoiceOver or NVDA on the homepage and one form
A few things I'd avoid. Don't add ARIA roles to native elements "just to be safe": <button role="button"> is noise, and role="presentation" on the wrong element can hide real content. Don't build a custom select, date picker or modal from scratch if a native element or a well-tested library like Radix, Headless UI or React Aria covers it, because getting keyboard and screen reader behavior right takes far more work than the visuals. And I wouldn't rely on accessibility overlay widgets that promise one-line compliance. Many disabled users and accessibility experts have criticised them, and they don't fix the underlying markup.
Where you can relax: AAA across the whole site is usually not worth chasing, and an internal admin tool used by five people doesn't need the same polish as a public checkout. It still needs keyboard access and labels, though, because you don't know who'll join the team next year.