Web Accessibility Essentials Every Developer Should Know
Practical accessibility patterns that improve usability for everyone while keeping your app legally compliant and semantically correct.

Most accessibility fixes are not heroic refactors. They are small, embarrassingly simple changes that you skipped because nobody complained — yet. The reality is that 15-20% of users experience some form of disability, and inaccessible interfaces silently push them away without ever filing a bug report.
Accessibility is not a feature to bolt on later. It is a baseline quality standard, like handling errors or writing tests.
Semantic HTML Is the Foundation
The single highest-impact accessibility improvement costs zero extra effort: use the right HTML elements. A <button> already handles keyboard focus, Enter/Space activation, and screen reader announcements. A <div onClick> does none of that.
<!-- ❌ Inaccessible — no keyboard support, no role, no focus -->
<div class="btn" onclick="handleClick()">
Submit
</div>
<!-- ✅ Accessible by default — focus, keyboard, screen reader support -->
<button type="submit" onclick="handleClick()">
Submit
</button>The same principle applies everywhere: <nav> instead of <div class="nav">, <main> instead of <div class="content">, <h2> instead of <div class="heading">. Every semantic element carries implicit ARIA roles that assistive technology relies on.
Common Semantic Mistakes
<!-- ❌ Heading hierarchy skipped — confuses screen reader navigation -->
<h1>Page Title</h1>
<h4>Section Title</h4>
<!-- ✅ Sequential heading levels -->
<h1>Page Title</h1>
<h2>Section Title</h2>Screen readers generate a table of contents from headings. Skipping levels creates a broken outline that makes navigation painful for users who rely on it.
Keyboard Navigation Patterns
Every interactive element must be reachable and operable via keyboard alone. This means managing focus order, visible focus indicators, and keyboard event handlers.
/* ❌ Destroys keyboard accessibility — users can't see where they are */
*:focus {
outline: none;
}
/* ✅ Custom focus style that's visible and on-brand */
*:focus-visible {
outline: 2px solid #4A90D9;
outline-offset: 2px;
border-radius: 2px;
}The :focus-visible pseudo-class is the correct solution — it only shows the outline for keyboard navigation, not mouse clicks. This addresses the designer complaint about "ugly outlines" without sacrificing accessibility.
Focus Trapping in Modals
When a modal opens, focus must stay inside it. Otherwise, keyboard users tab behind the modal into invisible content.
function trapFocus(modalElement: HTMLElement) {
const focusableSelectors = [
'button', '[href]', 'input', 'select',
'textarea', '[tabindex]:not([tabindex="-1"])'
];
const focusable = modalElement.querySelectorAll(
focusableSelectors.join(', ')
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
modalElement.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}Modern HTML provides <dialog> with built-in focus trapping, but custom modals still need explicit management.
ARIA: Use Sparingly and Correctly
ARIA attributes should fill gaps where HTML semantics fall short — not replace them. The first rule of ARIA is literally "don't use ARIA" if a native HTML element already does the job.
<!-- ❌ Redundant — button already has role="button" -->
<button role="button" aria-label="Submit">Submit</button>
<!-- ✅ ARIA adds missing context where needed -->
<button aria-expanded="false" aria-controls="dropdown-menu">
Options
</button>
<ul id="dropdown-menu" role="menu" hidden>
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>The most useful ARIA attributes for everyday development:
aria-label— labels elements without visible textaria-expanded— communicates toggle statearia-live— announces dynamic content changesaria-hidden="true"— hides decorative elements from screen readers
Live Regions for Dynamic Content
When content updates without a page reload (toast notifications, form validation, live data), screen readers need explicit notification.
function announceMessage(message: string, priority: 'polite' | 'assertive' = 'polite') {
const announcer = document.getElementById('live-announcer');
if (!announcer) return;
announcer.setAttribute('aria-live', priority);
announcer.textContent = '';
// Force DOM to register the empty state before updating
requestAnimationFrame(() => {
announcer.textContent = message;
});
}Place an invisible live region in your layout once, and reuse it for all dynamic announcements.
Color Contrast and Visual Design
WCAG 2.1 requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text. This is not a suggestion — it is the difference between readable and illegible for millions of users.
/* ❌ Fails contrast — ratio 2.3:1 */
.subtle-text {
color: #B0B0B0;
background: #FFFFFF;
}
/* ✅ Passes AA contrast — ratio 4.6:1 */
.subtle-text {
color: #767676;
background: #FFFFFF;
}Never rely on color alone to communicate meaning. Error states need both red color and an icon or text label. Status indicators need shape or pattern differences, not just color.
Forms: The Accessibility Minefield
Forms accumulate more accessibility violations than any other component. The fix is almost always connecting labels to inputs.
<!-- ❌ No programmatic label — screen reader says "edit text" -->
<input type="email" placeholder="Enter your email" />
<!-- ✅ Explicit label association -->
<label for="email-input">Email address</label>
<input id="email-input" type="email" placeholder="you@example.com" />Placeholders are not labels. They disappear on focus, fail contrast requirements, and provide no persistent context. Every input needs a <label> with a matching for attribute.
For error messages, use aria-describedby to connect the error to the input:
<label for="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-error"
aria-invalid="true"
/>
<span id="password-error" role="alert">
Password must be at least 8 characters
</span>Testing Accessibility
Automated tools catch about 30% of accessibility issues. The rest require manual testing. A reasonable testing strategy combines both.
Automated: Run axe-core or Lighthouse accessibility audits in CI. They catch low-hanging fruit: missing alt text, broken label associations, contrast failures.
Manual checks:
- Tab through the entire page — can you reach and operate everything?
- Use a screen reader (VoiceOver, NVDA) for at least the critical flows
- Zoom to 200% — does the layout still work?
- Disable CSS — is the content order logical?
The browser DevTools accessibility tree shows exactly what assistive technology sees. If an element is missing from the tree or has the wrong role, that is the bug.
Key Takeaways
- Semantic HTML solves 50% of accessibility issues — use the right elements before reaching for ARIA
- Never remove focus outlines — use
:focus-visiblefor a keyboard-only visible style - Labels are mandatory — every form input needs an explicit
<label>, not just a placeholder - Color alone is insufficient — always pair color with text, icons, or patterns
- Test with keyboard and screen readers — automated tools only catch a fraction of real issues
- ARIA is a last resort — the first rule of ARIA is to not use ARIA when native HTML works


