Loading
Learn semantic HTML, ARIA attributes, keyboard navigation, color contrast, and screen reader testing to build websites everyone can use.
Over one billion people worldwide have some form of disability. Accessibility is not a feature — it is a baseline. A website that only works for sighted mouse users with perfect vision is a broken website. The good news: most accessibility fixes are straightforward and make the experience better for everyone.
The single highest-impact accessibility fix is using the right HTML elements. Screen readers, keyboard users, and browser features all depend on semantic meaning.
Key semantic elements and when to use them:
<header> — Site or section header<nav> — Navigation links<main> — Primary page content (one per page)<article> — Self-contained content (blog post, card)<section> — Thematic grouping with a heading<aside> — Tangentially related content (sidebar)<footer> — Site or section footer<button> — Clickable actions (not <div onclick>)<a> — Navigation to another page or resourceThe rule is simple: if an HTML element exists for what you are building, use it. A <button> comes with keyboard support, focus management, and screen reader announcements for free. A <div> with an onclick gets none of that.
ARIA (Accessible Rich Internet Applications) fills gaps where HTML semantics are not enough. But the first rule of ARIA is: do not use ARIA if a native HTML element works.
Where ARIA is genuinely needed:
Essential ARIA attributes:
aria-label — Provides an accessible name when there is no visible textaria-labelledby — Points to the ID of an element that labels this onearia-describedby — Points to the ID of an element that describes this onearia-live — Announces dynamic content changes (polite or assertive)aria-expanded — Indicates whether a collapsible section is openaria-hidden="true" — Hides decorative elements from screen readersEvery interactive element must be operable with a keyboard. Many users cannot use a mouse — people with motor disabilities, power users, and screen reader users all navigate with keyboards.
Test keyboard navigation by pressing Tab through your entire page:
Tab moves forward through interactive elementsShift+Tab moves backwardEnter activates links and buttonsSpace activates buttons and checkboxesEscape closes modals and dropdownsCommon keyboard traps and how to fix them:
WCAG 2.1 requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text (18px bold or 24px regular).
Tools to check contrast:
Common failures and fixes:
Do not rely on color alone to communicate information:
Every <img> needs an alt attribute. The content of that attribute depends on the image's purpose.
For video content, provide captions. For audio, provide transcripts.
Automated tools catch about 30% of accessibility issues. You need to test manually as well.
Automated testing:
Run Lighthouse in Chrome DevTools (Audits tab) with the Accessibility category checked. Also install the axe DevTools browser extension for more detailed reporting.
Keyboard testing:
Put your mouse in a drawer. Navigate your entire application with only the keyboard. Can you reach every interactive element? Can you see where focus is? Can you operate every control?
Screen reader testing:
Cmd+F5 to toggle it.A quick accessibility checklist:
Accessibility is not a checklist you complete once. It is a practice you build into every feature from the start. Fixing it retroactively costs ten times more than building it right the first time.
<!-- Bad: divs with no meaning -->
<div class="header">
<div class="nav">
<div class="link" onclick="navigate()">Home</div>
</div>
</div>
<div class="main">
<div class="title">Welcome</div>
</div>
<!-- Good: semantic elements -->
<header>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
<h1>Welcome</h1>
</main>[ ] All images have appropriate alt text
[ ] All form inputs have associated labels
[ ] Color contrast meets 4.5:1 minimum
[ ] All functionality is keyboard accessible
[ ] Focus order is logical (follows DOM order)
[ ] Focus indicator is visible on all interactive elements
[ ] Page has a single h1 and logical heading hierarchy
[ ] Dynamic content changes are announced (aria-live)
[ ] Modals trap focus and can be closed with Escape
[ ] No content is conveyed only through color<!-- Don't do this — a native button is better -->
<div role="button" tabindex="0" aria-label="Submit">Submit</div>
<!-- Do this -->
<button type="submit">Submit</button><!-- Label an icon-only button -->
<button aria-label="Close dialog">
<svg><!-- X icon --></svg>
</button>
<!-- Describe the current state of a toggle -->
<button aria-pressed="true">Dark Mode</button>
<!-- Connect an error message to its input -->
<label for="email">Email</label>
<input id="email" type="email" aria-describedby="email-error" />
<span id="email-error" role="alert">Please enter a valid email</span>
<!-- Mark a loading region -->
<div aria-live="polite" aria-busy="true">Loading results...</div>
<!-- Label a region when there's no visible heading -->
<nav aria-label="Main navigation">...</nav>
<nav aria-label="Footer navigation">...</nav>/* Never do this — it removes the focus indicator */
*:focus {
outline: none;
}
/* Do this — style focus visibly */
:focus-visible {
outline: 2px solid #10b981;
outline-offset: 2px;
}// Modal must trap focus inside it and return focus when closed
function Modal({ isOpen, onClose, children }: ModalProps) {
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
// Also handle Escape key
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
if (isOpen) document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true">
{children}
</div>
);
}/* Fails: light gray on white = 2.5:1 ratio */
.subtitle {
color: #999999;
background: #ffffff;
}
/* Passes: darker gray on white = 4.6:1 ratio */
.subtitle {
color: #6b6b6b;
background: #ffffff;
}<!-- Bad: only color indicates an error -->
<input style="border-color: red;" />
<!-- Good: color + icon + text -->
<input style="border-color: red;" aria-describedby="name-error" />
<span id="name-error"> ⚠ Name is required </span><!-- Informative image: describe what it shows -->
<img src="chart.png" alt="Bar chart showing 40% growth in Q3 2024" />
<!-- Functional image (inside a link or button): describe the action -->
<a href="/">
<img src="logo.png" alt="DURA home page" />
</a>
<!-- Decorative image: use empty alt -->
<img src="divider.png" alt="" />
<!-- Complex image: use aria-describedby for a longer description -->
<img src="architecture.png" alt="System architecture diagram" aria-describedby="arch-desc" />
<p id="arch-desc">
The system consists of three layers: a React frontend communicating with a Node.js API, which
connects to a PostgreSQL database.
</p>