Why accessibility matters
Accessibility (a11y) is the practice of building websites that everyone can use, including people with disabilities. It’s not only ethical but also expands your audience and improves SEO.
Semantic markup
Correct HTML tags are the foundation of accessibility:
<!-- ❌ Bad -->
<div class="header">
<div class="nav">
<div class="link" onclick="...">Home</div>
</div>
</div>
<!-- ✅ Good -->
<header>
<nav aria-label="Main navigation">
<a href="/">Home</a>
<a href="/en/about">About</a>
</nav>
</header>
<main>
<article>
<h1>Page heading</h1>
<section>
<h2>Subheading</h2>
<p>Content...</p>
</section>
</article>
</main>
<footer>
<nav aria-label="Secondary navigation">...</nav>
</footer>
ARIA attributes
// Icon button
<button aria-label="Close modal">
<XIcon />
</button>
// Modal window
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Confirmation</h2>
<p id="modal-description">Are you sure?</p>
</div>
// Loading state
<button aria-busy={isLoading} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Submit'}
</button>
// Notifications
<div role="alert" aria-live="polite">
Form submitted successfully
</div>
// Navigation with the current page
<nav>
<a href="/" aria-current="page">Home</a>
<a href="/en/about">About</a>
</nav>
// Dropdown menu
<button
aria-expanded={isOpen}
aria-controls="dropdown-menu"
aria-haspopup="true"
>
Menu
</button>
<ul id="dropdown-menu" role="menu" hidden={!isOpen}>
<li role="menuitem"><a href="#">Item 1</a></li>
</ul>
Focus management
// Focus trap for the modal
function Modal({ isOpen, onClose, children }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement>();
useEffect(() => {
if (isOpen) {
previousFocus.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
previousFocus.current?.focus();
}
}, [isOpen]);
// Focus trap
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
const focusable = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusable?.length) return;
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
onClose();
}
};
return (
<div
ref={modalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onKeyDown={handleKeyDown}
>
{children}
</div>
);
}
// Skip link for keyboard navigation
<a href="#main-content" className="skip-link">
Skip to main content
</a>
// CSS for the skip link
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
Contrast and colors
/* WCAG AA requires 4.5:1 contrast for text */
/* WCAG AAA requires 7:1 contrast */
/* ❌ Bad — low contrast */
.text-low-contrast {
color: #999;
background: #fff;
}
/* ✅ Good — sufficient contrast */
.text-good-contrast {
color: #595959;
background: #fff;
}
/* Don’t rely on color alone */
/* ❌ Bad */
.error { color: red; }
/* ✅ Good — color + icon + text */
.error {
color: #dc2626;
display: flex;
align-items: center;
gap: 0.5rem;
}
.error::before {
content: '⚠️';
}
/* Focus must be visible */
:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Don’t remove outline without a replacement */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
Forms
// Linking label and input
<label htmlFor="email">Email</label>
<input id="email" type="email" required />
// Describing errors
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-error password-hint"
aria-invalid={!!error}
/>
<p id="password-hint">At least 8 characters</p>
{error && <p id="password-error" role="alert">{error}</p>}
</div>
// Grouping fields
<fieldset>
<legend>Delivery method</legend>
<label>
<input type="radio" name="delivery" value="pickup" />
Pickup
</label>
<label>
<input type="radio" name="delivery" value="courier" />
Courier
</label>
</fieldset>
Images
// Informative image
<img src="/product.jpg" alt="Red backpack with two pockets" />
// Decorative image
<img src="/decoration.svg" alt="" role="presentation" />
// Complex image (chart)
<figure>
<img
src="/chart.png"
alt="Sales chart for 2024"
aria-describedby="chart-description"
/>
<figcaption id="chart-description">
Sales grew 25% in Q4 compared to Q1
</figcaption>
</figure>
Accessibility testing
# Tools
# - axe DevTools (browser extension)
# - Lighthouse (built into Chrome)
# - WAVE (wave.webaim.org)
# - Screen readers: NVDA (Windows), VoiceOver (Mac)
# Automated tests
npm install @axe-core/playwright
// Playwright test
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage should have no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Related articles
Conclusion
Accessibility is not an extra feature but a basic requirement. Semantic markup, ARIA attributes, focus management and sufficient contrast make a site accessible to everyone. Test with screen readers and automated tools.
An accessible site is a good site. a11y practices improve the UX for all users.



