| name | accessibility |
| description | Audit and improve web accessibility following WCAG 2.1 guidelines. Use when asked to "improve accessibility", "a11y audit", "WCAG compliance", "screen reader support", "keyboard navigation", or "make accessible". |
| license | MIT |
| metadata | {"author":"web-quality-skills","version":"1.0"} |
| version | 1.0.0 |
Accessibility (a11y)
Comprehensive accessibility guidelines based on WCAG 2.1 and Lighthouse accessibility audits. Goal: make content usable by everyone, including people with disabilities.
WCAG Principles: POUR
| Principle | Description |
|---|
| Perceivable | Content can be perceived through different senses |
| Operable | Interface can be operated by all users |
| Understandable | Content and interface are understandable |
| Robust | Content works with assistive technologies |
Conformance levels
| Level | Requirement | Target |
|---|
| A | Minimum accessibility | Must pass |
| AA | Standard compliance | Should pass (legal requirement in many jurisdictions) |
| AAA | Enhanced accessibility | Nice to have |
Perceivable
Text alternatives (1.1)
Images require alt text:
<img src="chart.png">
<img src="chart.png" alt="Bar chart showing 40% increase in Q3 sales">
<img src="decorative-border.png" alt="" role="presentation">
<figure>
<img src="infographic.png" alt="2024 market trends infographic"
aria-describedby="infographic-desc">
<figcaption id="infographic-desc">
</figcaption>
</figure>
Icon buttons need accessible names:
<button><svg></svg></button>
<button aria-label="Open menu">
<svg aria-hidden="true"></svg>
</button>
<button>
<svg aria-hidden="true"></svg>
<span class="visually-hidden">Open menu</span>
</button>
Visually hidden class:
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
Color contrast (1.4.3, 1.4.6)
| Text Size | AA minimum | AAA enhanced |
|---|
| Normal text (< 18px / < 14px bold) | 4.5:1 | 7:1 |
| Large text (≥ 18px / ≥ 14px bold) | 3:1 | 4.5:1 |
| UI components & graphics | 3:1 | 3:1 |
.low-contrast {
color: #999;
background: #fff;
}
.high-contrast {
color: #333;
background: #fff;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
Don't rely on color alone:
<input class="error-border">
<style>.error-border { border-color: red; }</style>
<div class="field-error">
<input aria-invalid="true" aria-describedby="email-error">
<span id="email-error" class="error-message">
<svg aria-hidden="true"></svg>
Please enter a valid email address
</span>
</div>
Operable
Keyboard accessible (2.1)
All functionality must be keyboard accessible:
element.addEventListener('click', handleAction);
element.addEventListener('click', handleAction);
element.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleAction();
}
});
No keyboard traps:
function openModal(modal) {
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
if (e.key === 'Escape') {
closeModal();
}
});
firstElement.focus();
}
Focus visible (2.4.7)
*:focus { outline: none; }
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
button:focus-visible {
box-shadow: 0 0 0 3px rgba(0, 95, 204, 0.5);
}
Skip links (2.4.1)
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header></header>
<main id="main-content" tabindex="-1">
</main>
</body>
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
Motion (2.3)
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
Understandable
Page language (3.1.1)
<html>
<html lang="en">
Form labels (3.3.2)
<input type="email" placeholder="Email">
<label for="email">Email address</label>
<input type="email" id="email" name="email"
autocomplete="email" required>
<label for="password">Password</label>
<input type="password" id="password"
aria-describedby="password-requirements">
<p id="password-requirements">
Must be at least 8 characters with one number.
</p>
Error handling (3.3.1, 3.3.3)
<form novalidate>
<div class="field" aria-live="polite">
<label for="email">Email</label>
<input type="email" id="email"
aria-invalid="true"
aria-describedby="email-error">
<p id="email-error" class="error" role="alert">
Please enter a valid email address (e.g., name@example.com)
</p>
</div>
</form>
Robust
Valid HTML (4.1.1)
<div id="content">...</div>
<div id="content">...</div>
<div id="main-content">...</div>
<div id="sidebar-content">...</div>
ARIA usage (4.1.2)
Prefer native elements:
<div role="button" tabindex="0">Click me</div>
<button>Click me</button>
When ARIA is needed:
<div role="tablist" aria-label="Map filters">
<button role="tab" id="tab-1" aria-selected="true"
aria-controls="panel-1">Countries</button>
<button role="tab" id="tab-2" aria-selected="false"
aria-controls="panel-2" tabindex="-1">Cities</button>
</div>
Live regions (4.1.3)
<div aria-live="polite" aria-atomic="true" class="status">
</div>
<div role="alert" aria-live="assertive">
</div>
const showNotification = (message, type = 'polite') => {
const container = document.getElementById(`${type}-announcer`);
container.textContent = '';
requestAnimationFrame(() => {
container.textContent = message;
});
};
StarMapper-specific notes
- MapLibre canvas: the map itself is not keyboard-navigable (known limitation). Add an accessible text description of what the map shows.
- Popups: MapLibre popups injected via
innerHTML must sanitize content. Use textContent for user-provided data (location strings, usernames).
- Progress indicators: chunk loop progress bar must announce % loaded to screen readers via
aria-valuenow + aria-valuemin + aria-valuemax.
- Token modal: focus must be trapped within the modal when open. Escape closes it.
- Unmapped drawer: animate with
transform, respect prefers-reduced-motion.
- Filter combobox: follows ARIA combobox pattern —
role="combobox", aria-expanded, aria-controls.
Testing checklist
Automated testing
npx lighthouse https://starmapper.bruniaux.com --only-categories=accessibility
npm install @axe-core/cli -g
axe https://starmapper.bruniaux.com
Manual testing
Common issues by impact
Critical (fix immediately)
- Missing form labels
- Missing image alt text
- Insufficient color contrast
- Keyboard traps
- No focus indicators
Serious (fix before launch)
- Missing page language
- Missing heading structure
- Non-descriptive link text
- Missing skip links
Moderate (fix soon)
- Missing ARIA labels on icons
- Missing error identification
- Missing landmark regions
References