Accessibility Guide
Sightline is committed to providing an accessible experience for all users. This guide covers our accessibility features and how to maintain them in your customizations.
Accessibility Standards
Sightline aims to meet WCAG 2.1 Level AA compliance. Key areas include:
- Perceivable — Content is available to all senses
- Operable — Interface is navigable and usable
- Understandable — Content and operation are clear
- Robust — Works with assistive technologies
Built-in Features
Keyboard Navigation
All interactive elements are keyboard accessible:
| Action | Keys |
|---|---|
| Navigate between elements | Tab / Shift + Tab |
| Activate buttons | Enter / Space |
| Close popups/modals | Escape |
| Navigate timeline | ← / → Arrow keys |
| Zoom map | + / - |
| Pan map | Arrow keys |
See Keyboard Shortcuts for the complete list.
Focus Management
- Visible focus indicators on all interactive elements
- Focus trapped within modals when open
- Focus returned to trigger element when closing
/* Focus styles in globals.css */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}Color Contrast
Default theme colors meet WCAG AA contrast requirements:
| Element | Contrast Ratio | Requirement |
|---|---|---|
| Body text | 7:1+ | AA (4.5:1) |
| Large text | 5:1+ | AA (3:1) |
| UI components | 4.5:1+ | AA (3:1) |
When customizing colors, verify contrast ratios using tools like WebAIM Contrast Checker.
Screen Reader Support
- Semantic HTML structure
- ARIA labels on interactive elements
- Live regions for dynamic content updates
- Descriptive alt text for images
Component Accessibility
Markers
<button
onClick={onClick}
aria-label={`Incident at ${incident.location?.name}, ${incident.temporal?.date}`}
className="..."
>
{/* Marker visual */}
</button>Popups
<div
role="dialog"
aria-labelledby="popup-title"
aria-describedby="popup-description"
aria-modal="true"
>
<h2 id="popup-title">{incident.location?.name}</h2>
<p id="popup-description">{incident.summary}</p>
<button aria-label="Close popup" onClick={onClose}>
<X aria-hidden="true" />
</button>
</div>Feed Items
<button
onClick={onClick}
aria-selected={isSelected}
aria-label={`${incident.location?.name}, ${incident.temporal?.date}`}
>
{/* Feed item content */}
</button>Filters
<fieldset>
<legend className="sr-only">Filter by incident type</legend>
{options.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={selected.includes(option.value)}
onChange={() => toggle(option.value)}
aria-describedby={`${option.value}-description`}
/>
<span>{option.label}</span>
<span id={`${option.value}-description`} className="sr-only">
{option.description}
</span>
</label>
))}
</fieldset>Map Accessibility
Mapbox GL provides several accessibility features:
Navigation
map: {
// Enable keyboard navigation
keyboardShortcuts: true,
}ARIA Labels
The map container includes appropriate ARIA attributes:
<div
role="application"
aria-label="Interactive incident map"
aria-describedby="map-instructions"
>
<Map ... />
<div id="map-instructions" className="sr-only">
Use arrow keys to pan, plus and minus to zoom.
Tab to navigate to incident markers.
</div>
</div>Alternative Content
For users who cannot access the map, provide alternative views:
{/* Toggle between map and list view */}
<div role="tablist">
<button role="tab" aria-selected={view === 'map'}>Map View</button>
<button role="tab" aria-selected={view === 'list'}>List View</button>
</div>
{view === 'list' && (
<table aria-label="Incident list">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Location</th>
<th scope="col">Type</th>
</tr>
</thead>
<tbody>
{incidents.map((incident) => (
<tr key={incident.id}>
<td>{incident.temporal?.date}</td>
<td>{incident.location?.name}</td>
<td>{incident.classification?.incidentType}</td>
</tr>
))}
</tbody>
</table>
)}Theme Accessibility
Respecting User Preferences
theme: {
defaultTheme: 'system', // Respects prefers-color-scheme
}Reduced Motion
Respect users who prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}In components:
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
<motion.div
animate={{ scale: 1.1 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.3
}}
/>High Contrast Mode
Support Windows High Contrast mode:
@media (forced-colors: active) {
.incident-marker {
forced-color-adjust: none;
border: 2px solid CanvasText;
}
.button {
border: 1px solid ButtonText;
}
}Forms and Inputs
Labels
Always associate labels with inputs:
{/* Explicit label */}
<label htmlFor="search-input">Search incidents</label>
<input id="search-input" type="search" />
{/* Implicit label */}
<label>
Search incidents
<input type="search" />
</label>
{/* Visually hidden but accessible */}
<label htmlFor="search" className="sr-only">Search incidents</label>
<input id="search" type="search" placeholder="Search..." />Error Messages
<div>
<label htmlFor="date">Date</label>
<input
id="date"
type="date"
aria-invalid={!!error}
aria-describedby={error ? 'date-error' : undefined}
/>
{error && (
<p id="date-error" role="alert" className="text-red-500">
{error}
</p>
)}
</div>Required Fields
<label htmlFor="location">
Location
<span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</span>
</label>
<input id="location" required aria-required="true" />Live Regions
Announce dynamic updates to screen readers:
// Stats updates
<div aria-live="polite" aria-atomic="true">
Showing {filteredCount} of {totalCount} incidents
</div>
// Unified loading states (data + map ready)
const [mapReady, setMapReady] = useState(false);
const appReady = !isLoading && mapReady;
<div aria-live="assertive" aria-busy={!appReady}>
{!appReady ? 'Initializing...' : 'Application ready'}
</div>
// Filter changes
useEffect(() => {
announceToScreenReader(`Filtered to ${filteredCount} incidents`);
}, [filteredCount]);
function announceToScreenReader(message: string) {
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'polite');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
setTimeout(() => announcement.remove(), 1000);
}Testing Accessibility
Automated Testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('should not have any automatically detectable accessibility issues', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.mapboxgl-map');
const accessibilityScanResults = await new AxeBuilder({ page }).analyze();
expect(accessibilityScanResults.violations).toEqual([]);
});
});Manual Testing Checklist
- Navigate entire app using only keyboard
- Test with screen reader (VoiceOver, NVDA, JAWS)
- Verify color contrast meets requirements
- Check focus indicators are visible
- Test with 200% browser zoom
- Test with reduced motion preference
- Verify all images have alt text
- Check form error handling
Testing Tools
- axe DevTools — Browser extension
- WAVE — Web accessibility evaluation
- Lighthouse — Built into Chrome DevTools
- VoiceOver — macOS screen reader
- NVDA — Windows screen reader
CSS Utilities
Screen Reader Only
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}Skip Links
<body>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded"
>
Skip to main content
</a>
{/* ... */}
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>