Building Accessible Web Components: A Developer’s Complete Guide
Accessibility isn’t a feature—it’s a fundamental requirement for building inclusive web experiences. Yet many developers struggle with implementing accessibility correctly, often due to outdated advice or incomplete understanding of how assistive technologies work.
This guide provides practical patterns for building accessible components, tested with real screen readers and keyboard users.
The Accessibility Mindset
Before diving into code, let’s establish core principles:
1. Start with Semantic HTML
The vast majority of accessibility issues stem from using the wrong HTML elements:
<!-- BAD: Div pretending to be a button -->
<div class="btn" onclick="submit()">Submit</div>
<!-- GOOD: Actual button -->
<button type="submit">Submit</button>
The native <button> provides:
- Keyboard focusability
- Enter/Space activation
- Form submission
- Screen reader announcement as “button”
- No ARIA needed
2. Don’t Override Native Behavior
Native HTML elements have decades of accessibility built in. When you override them, you inherit responsibility for all that functionality.
3. Test with Real Tools
- VoiceOver (macOS/iOS)
- NVDA (Windows, free)
- JAWS (Windows, commercial)
- Keyboard-only navigation
Accessible Button Patterns
Standard Buttons
---
interface Props {
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
loading?: boolean;
}
const { variant = 'primary', disabled, loading } = Astro.props;
---
<button
class:list={['btn', `btn-${variant}`]}
disabled={disabled || loading}
aria-busy={loading}
>
{loading && (
<span class="spinner" aria-hidden="true" />
)}
<slot />
</button>
Icon Buttons
Icon-only buttons need accessible names:
---
interface Props {
icon: string;
label: string;
}
const { icon, label } = Astro.props;
---
<button
class="icon-btn"
aria-label={label}
title={label}
>
<Icon name={icon} aria-hidden="true" />
</button>
Toggle Buttons
---
interface Props {
pressed?: boolean;
label: string;
}
const { pressed = false, label } = Astro.props;
---
<button
class="toggle-btn"
aria-pressed={pressed}
>
{label}
</button>
<script>
document.querySelectorAll('.toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const current = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!current));
});
});
</script>
Accessible Modal Dialogs
Modals are notoriously difficult to implement accessibly. Here’s a complete pattern:
---
interface Props {
id: string;
title: string;
}
const { id, title } = Astro.props;
const titleId = `${id}-title`;
---
<dialog
id={id}
class="modal"
aria-labelledby={titleId}
aria-modal="true"
>
<div class="modal-content">
<header class="modal-header">
<h2 id={titleId}>{title}</h2>
<button
type="button"
class="modal-close"
aria-label="Close dialog"
data-close-modal
>
<span aria-hidden="true">×</span>
</button>
</header>
<div class="modal-body">
<slot />
</div>
</div>
</dialog>
<script>
class AccessibleModal {
constructor(dialog) {
this.dialog = dialog;
this.previousFocus = null;
this.setupListeners();
}
setupListeners() {
// Close button
this.dialog.querySelector('[data-close-modal]')
?.addEventListener('click', () => this.close());
// Escape key
this.dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape') this.close();
});
// Click outside
this.dialog.addEventListener('click', (e) => {
if (e.target === this.dialog) this.close();
});
// Focus trap
this.dialog.addEventListener('keydown', (e) => {
if (e.key === 'Tab') this.trapFocus(e);
});
}
open() {
this.previousFocus = document.activeElement;
this.dialog.showModal();
// Focus first focusable element
const focusable = this.dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
}
close() {
this.dialog.close();
this.previousFocus?.focus();
}
trapFocus(e) {
const focusables = this.dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
// Initialize all modals
document.querySelectorAll('dialog.modal').forEach(dialog => {
dialog._accessibleModal = new AccessibleModal(dialog);
});
</script>
Modal Checklist
- Uses native
<dialog>element - Has
aria-labelledbypointing to title - Has
aria-modal="true" - Focus moves to modal when opened
- Focus returns to trigger when closed
- Focus is trapped within modal
- Escape key closes modal
- Click outside closes modal
- Close button has accessible name
Accessible Forms
Text Inputs with Error States
---
interface Props {
id: string;
label: string;
error?: string;
required?: boolean;
}
const { id, label, error, required } = Astro.props;
const errorId = `${id}-error`;
---
<div class="form-field">
<label for={id}>
{label}
{required && <span aria-hidden="true">*</span>}
</label>
<input
type="text"
id={id}
name={id}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
/>
{error && (
<span id={errorId} class="error" role="alert">
{error}
</span>
)}
</div>
Form Validation Announcements
// Announce form errors to screen readers
function announceErrors(errors: string[]) {
const announcer = document.getElementById('form-announcer');
if (!announcer) return;
// Clear previous announcement
announcer.textContent = '';
// Announce after brief delay (helps with screen reader timing)
setTimeout(() => {
announcer.textContent = `Form has ${errors.length} error${errors.length > 1 ? 's' : ''}: ${errors.join('. ')}`;
}, 100);
}
The announcer element:
<div
id="form-announcer"
role="status"
aria-live="polite"
class="sr-only"
></div>
Accessible Navigation
Skip Links
<a href="#main-content" class="skip-link">
Skip to main content
</a>
<nav aria-label="Main navigation">
<!-- navigation items -->
</nav>
<main id="main-content" tabindex="-1">
<!-- main content -->
</main>
<style>
.skip-link {
position: absolute;
top: -100%;
left: 16px;
padding: 8px 16px;
background: var(--accent);
color: white;
z-index: 100;
}
.skip-link:focus {
top: 16px;
}
</style>
Mobile Navigation Toggle
<button
id="menu-toggle"
aria-expanded="false"
aria-controls="main-nav"
aria-label="Open menu"
>
<span class="hamburger" aria-hidden="true"></span>
</button>
<nav id="main-nav" aria-label="Main">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/posts">Posts</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
<script>
const toggle = document.getElementById('menu-toggle');
const nav = document.getElementById('main-nav');
toggle?.addEventListener('click', () => {
const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!isExpanded));
toggle.setAttribute('aria-label', isExpanded ? 'Open menu' : 'Close menu');
nav?.classList.toggle('is-open');
});
</script>
Color and Contrast
Checking Contrast Programmatically
function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map(c => {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function getContrastRatio(color1: string, color2: string): number {
// Parse colors and calculate luminance
// Returns ratio like 4.5 or 7.0
}
// WCAG requirements:
// - 4.5:1 for normal text (Level AA)
// - 3:1 for large text (Level AA)
// - 7:1 for normal text (Level AAA)
Focus Indicators
Never remove focus indicators without replacement:
/* BAD: Removes all focus indication */
:focus {
outline: none;
}
/* GOOD: Custom focus indicator */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* For older browsers */
:focus:not(:focus-visible) {
outline: none;
}
Screen Reader Only Content
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Allow focus for skip links */
.sr-only:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
}
Usage:
<!-- Icon button with visible icon, invisible label -->
<button>
<svg aria-hidden="true"><!-- icon --></svg>
<span class="sr-only">Delete item</span>
</button>
<!-- Provide context for screen readers -->
<a href="/post">
Read more <span class="sr-only">about Accessibility</span>
</a>
Testing Your Components
Keyboard Testing Checklist
- Tab through all interactive elements
- Shift+Tab goes backwards correctly
- Enter/Space activates buttons and links
- Escape closes modals and menus
- Arrow keys work in menus and tabs
- Focus is visible at all times
- Focus order is logical
Screen Reader Testing
# macOS VoiceOver
# Cmd + F5 to enable
# Navigation
# VO + Right/Left Arrow: Navigate by element
# VO + H: Next heading
# VO + J: Next form control
# VO + Command + L: Next link
Automated Testing
// Using axe-core
import AxeBuilder from '@axe-core/playwright';
test('page should be accessible', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Common ARIA Mistakes
1. Redundant Roles
<!-- BAD: Button already has role="button" -->
<button role="button">Submit</button>
<!-- GOOD: Native semantics -->
<button>Submit</button>
2. Empty ARIA Labels
<!-- BAD: Empty label -->
<button aria-label="">X</button>
<!-- GOOD: Descriptive label -->
<button aria-label="Close dialog">X</button>
3. aria-hidden on Focusable Elements
<!-- BAD: Hidden but focusable -->
<button aria-hidden="true">Click me</button>
<!-- GOOD: Use inert or disabled instead -->
<button disabled>Click me</button>
Conclusion
Accessibility is a practice, not a checklist. The patterns in this guide provide a foundation, but the real test is whether people can actually use your components.
Key takeaways:
- Start with semantic HTML - It solves most problems
- Test with real tools - Automated tests catch ~30% of issues
- Consider keyboard users - They’re more common than you think
- Announce dynamic changes - Screen readers need help with updates
- Never remove focus indicators - Replace them with better ones
Building accessible components takes practice, but each accessible component you build benefits everyone who uses the web.
Resources: