Web accessibility (a11y) is about building interfaces that work for everyone — including people who use screen readers, keyboard navigation, switch controls, or have cognitive disabilities. It's also increasingly a legal requirement in many jurisdictions.
The good news: accessible HTML is mostly just correct HTML. Most accessibility mistakes come from using the wrong element for a job, or overriding browser defaults that were already correct.
Semantic HTML First
The single most impactful accessibility improvement is using semantic HTML:
<!-- Wrong: everything is a div -->
<div class="button" onclick="submit()">Submit</div>
<div class="header"><div class="nav">...</div></div>
<!-- Right: semantic elements -->
<button type="button" onclick="submit()">Submit</button>
<header><nav>...</nav></header>
Semantic elements come with built-in accessibility: <button> is keyboard-focusable, activatable with Enter/Space, and announced as "button" by screen readers. A <div> is none of these things without significant ARIA work.
Key Semantic Elements
<main> <!-- primary page content, one per page -->
<header> <!-- page or section header -->
<nav> <!-- navigation links -->
<aside> <!-- secondary content (sidebar, related links) -->
<footer> <!-- page or section footer -->
<article> <!-- self-contained content (blog post, comment) -->
<section> <!-- thematic grouping with a heading -->
<h1> - <h6> <!-- headings: use in order, don't skip levels -->
<ul>, <ol> <!-- lists: screen readers announce item count -->
<table> <!-- tabular data: NOT for layout -->
<figure> <!-- image with caption -->
<figcaption> <!-- caption for figure -->
Images
Every image needs an alt attribute:
<!-- Informative image: describe what the image shows -->
<img src="chart.png" alt="Bar chart showing 40% increase in Firefox users Q3 2024">
<!-- Decorative image: empty alt, screen readers skip it -->
<img src="decorative-line.svg" alt="">
<!-- Icon button: describe the action, not the icon -->
<button>
<img src="delete.svg" alt="Delete item">
</button>
<!-- Complex image: use a figcaption for detail -->
<figure>
<img src="architecture-diagram.png" alt="System architecture diagram showing three services">
<figcaption>The data pipeline connects the ingestion service to the processing queue and storage layer.</figcaption>
</figure>
SVG icons inline in buttons need a title or aria-label:
<button aria-label="Close dialog">
<svg aria-hidden="true" focusable="false"><!-- icon --></svg>
</button>
Forms
<form>
<!-- Every input needs a label -->
<label for="email">Email address</label>
<input type="email" id="email" name="email" required
autocomplete="email"
aria-describedby="email-hint">
<p id="email-hint">We'll never share your email.</p>
<!-- Error messages -->
<input type="text" id="username"
aria-invalid="true"
aria-describedby="username-error">
<p id="username-error" role="alert">Username already taken</p>
<!-- Group related inputs -->
<fieldset>
<legend>Notification preferences</legend>
<label>
<input type="checkbox" name="email-notifs"> Email notifications
</label>
<label>
<input type="checkbox" name="sms-notifs"> SMS notifications
</label>
</fieldset>
</form>
aria-describedby links the input to its hint/error message. Screen readers announce the description after the input label.
role="alert" announces content immediately when it's added to the DOM — good for validation errors.
Keyboard Navigation
All interactive elements must be keyboard-accessible:
// Keyboard event handler (don't rely only on click)
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
});
Tab order: Users navigate with Tab (forward) and Shift+Tab (backward). The default order follows DOM order — don't use tabindex values > 0 (they mess up natural order):
<!-- tabindex=0: include in tab order at natural position -->
<div role="button" tabindex="0">Custom button</div>
<!-- tabindex=-1: focusable programmatically, not via Tab -->
<div id="modal" tabindex="-1">Modal content</div>
<!-- tabindex=1+: avoid — overrides natural tab order -->
<input tabindex="3"> <!-- don't do this -->
Skip navigation: Let keyboard users skip to main content:
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content">...</main>
.skip-link {
position: absolute;
top: -100%;
}
.skip-link:focus {
top: 0; /* appears when focused */
}
ARIA Roles and Labels
ARIA (Accessible Rich Internet Applications) supplements HTML semantics. Use it when native HTML can't express the role:
<!-- Custom toggle button -->
<button aria-pressed="false" id="dark-mode-toggle">Dark mode</button>
<script>
const btn = document.getElementById('dark-mode-toggle');
btn.addEventListener('click', () => {
const pressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', !pressed);
});
</script>
<!-- Expanded/collapsed state -->
<button aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>...</ul>
<!-- Live regions: announce dynamic updates -->
<div aria-live="polite" id="status-message"></div>
<!-- Polite: announces after current speech ends -->
<!-- Assertive: interrupts immediately (use sparingly) -->
ARIA first rule: If a native HTML element has the semantics you need, use it. ARIA is for custom widgets only.
Color and Contrast
WCAG 2.1 requires a contrast ratio of 4.5:1 for normal text, 3:1 for large text (18pt+ or 14pt+ bold).
Check with browser DevTools:
- Firefox: Inspector → pick element → Accessibility panel → contrast ratio
- Or use WebAIM Contrast Checker
Never convey information through color alone:
<!-- Wrong: only color distinguishes the error -->
<span style="color: red">Required field</span>
<!-- Right: color + text/icon -->
<span style="color: red" aria-label="Required">
⚠ Required field
</span>
Focus Styles
Never just remove focus outlines — keyboard users rely on them:
/* Don't do this */
*:focus { outline: none; }
/* Better: custom focus style */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
:focus-visible only shows the outline when using keyboard navigation, not on mouse clicks. This is the modern approach — no need to suppress focus rings for mouse users.
Modal Dialogs
Modals have specific accessibility requirements:
// When modal opens:
// 1. Move focus into the modal
// 2. Trap focus inside
// 3. Return focus to trigger when closed
function openModal(modal, trigger) {
modal.removeAttribute('hidden');
modal.querySelector('[autofocus], button, [href], input').focus();
// Trap focus
modal.addEventListener('keydown', trapFocus);
// Close on Escape
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal(modal, trigger);
});
}
function closeModal(modal, trigger) {
modal.setAttribute('hidden', '');
trigger.focus(); // return focus
modal.removeEventListener('keydown', trapFocus);
}
Native <dialog> handles all of this automatically — use it:
<dialog id="my-modal">
<h2>Dialog title</h2>
<p>Content</p>
<button autofocus>Close</button>
</dialog>
document.getElementById('my-modal').showModal();
Firefox has supported native <dialog> since Firefox 98 (2022).
Testing Accessibility
Manual testing:
- Navigate the page with Tab only — can you reach everything?
- Test with Firefox Reader View — does the content make sense?
- Use Firefox Accessibility Inspector (DevTools → Accessibility tab)
Screen reader testing:
- macOS: VoiceOver (Cmd+F5)
- Windows: NVDA (free) or JAWS
- iOS: VoiceOver
- Android: TalkBack
Automated audits:
- Firefox DevTools: Accessibility panel → check for warnings
- Lighthouse:
npx lighthouse --categories=accessibility https://yoursite.com - axe browser extension
Automated tools catch about 30-40% of accessibility issues. Manual testing and real user testing are required for the rest.
The most important shift: think of accessibility not as a checklist to pass but as correct implementation. Semantic HTML, proper labels, and keyboard support aren't extras — they're how the web is supposed to work.
Top comments (0)