Accessibility Implementation Guide¶
Developer guide for implementing and maintaining WCAG 2.1 AA compliance.
This document covers the accessibility architecture, implementation patterns, and testing practices used in Home Security Intelligence. The frontend is designed to meet WCAG 2.1 AA compliance standards.
WCAG Compliance Overview¶
Target Compliance Level¶
WCAG 2.1 Level AA - This is the standard level of accessibility compliance that covers:
- Perceivable: Text alternatives, adaptable content, distinguishable content
- Operable: Keyboard accessible, enough time, seizures, navigable
- Understandable: Readable, predictable, input assistance
- Robust: Compatible with assistive technologies
Key Requirements Met¶
| WCAG Criterion | Description | Implementation |
|---|---|---|
| 1.1.1 Non-text Content | All images have text alternatives | aria-label, alt attributes |
| 1.3.1 Info and Relationships | Structure conveyed programmatically | Semantic HTML, ARIA roles |
| 1.4.3 Contrast (Minimum) | 4.5:1 for text, 3:1 for large text | WCAG-compliant color palette |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Focus indicators, borders |
| 2.1.1 Keyboard | All functionality via keyboard | useKeyboardShortcuts, focus management |
| 2.1.2 No Keyboard Trap | Focus can be moved away | Escape key handling, focus trap |
| 2.4.1 Bypass Blocks | Skip to main content | Skip link in Layout |
| 2.4.3 Focus Order | Logical tab order | tabIndex, DOM order |
| 2.4.7 Focus Visible | Visible focus indicator | Focus ring styles |
| 4.1.2 Name, Role, Value | ARIA labels for components | role, aria-* attributes |
Architecture¶
Key Files¶
frontend/src/
components/
layout/
Layout.tsx # Skip link, main content landmark
common/
ShortcutsHelpModal.tsx # Keyboard shortcuts help (?)
CommandPalette.tsx # Cmd+K navigation
AnimatedModal.tsx # Focus trap, Escape handling
hooks/
useKeyboardShortcuts.ts # Global keyboard navigation
useListNavigation.ts # j/k list navigation
config/
tourSteps.ts # Product tour configuration
frontend/tests/e2e/specs/
accessibility.spec.ts # axe-core WCAG tests
frontend/tailwind.config.js # WCAG-compliant color palette
Component Responsibilities¶
| Component | Accessibility Feature |
|---|---|
Layout.tsx | Skip link, main content landmark, keyboard shortcut integration |
AnimatedModal.tsx | Focus trap, Escape key handling, ARIA modal attributes |
ShortcutsHelpModal.tsx | Keyboard shortcuts reference |
CommandPalette.tsx | Keyboard navigation, fuzzy search |
VideoPlayer.tsx | Keyboard controls, ARIA labels for all buttons |
Lightbox.tsx | Keyboard image navigation, focus management |
Implementation Patterns¶
ARIA Labels¶
Every interactive element requires an accessible name. Use these patterns:
// Button with icon only - use aria-label
<button
onClick={handleClose}
aria-label="Close modal"
className="..."
>
<X className="h-5 w-5" />
</button>
// Button with visible text - no aria-label needed
<button onClick={handleSave} className="...">
Save Changes
</button>
// Icon with descriptive purpose
<AlertTriangle
className="h-4 w-4 text-yellow-500"
aria-label="Warning: Queue backing up"
/>
// Decorative icon - hide from screen readers
<Settings className="h-5 w-5" aria-hidden="true" />
Keyboard Navigation¶
The application uses three keyboard navigation systems:
1. Global Shortcuts (useKeyboardShortcuts)¶
// In Layout.tsx
useKeyboardShortcuts({
onOpenCommandPalette: () => setCommandPaletteOpen(true),
onOpenHelp: () => setShortcutsHelpOpen(true),
onEscape: () => {
setCommandPaletteOpen(false);
setShortcutsHelpOpen(false);
},
enabled: !isCommandPaletteOpen && !isShortcutsHelpOpen,
});
Key bindings:
?- Open keyboard shortcuts helpCmd/Ctrl + K- Open command paletteg + [letter]- Navigate to page (chord shortcuts)Escape- Close modal/cancel
2. List Navigation (useListNavigation)¶
const { selectedIndex, setSelectedIndex } = useListNavigation({
itemCount: events.length,
initialIndex: 0,
wrap: false,
onSelect: (index) => openEventDetail(events[index]),
enabled: true,
});
Key bindings:
j/ArrowDown- Move downk/ArrowUp- Move upHome- First itemEnd- Last itemEnter- Select
3. Component-Specific Shortcuts¶
Some components have their own keyboard handlers:
// VideoPlayer.tsx
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case ' ':
togglePlay();
break;
case 'f':
case 'F':
toggleFullscreen();
break;
case 'm':
case 'M':
toggleMute();
break;
case 'ArrowLeft':
seek(-5);
break;
case 'ArrowRight':
seek(5);
break;
}
}, []);
Focus Management¶
Skip Link¶
The Layout component includes a skip link for keyboard users:
// Layout.tsx
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-lg focus:bg-[#76B900] focus:px-4 focus:py-2 focus:font-medium focus:text-black"
>
Skip to main content
</a>
// Main content target
<main id="main-content" tabIndex={-1} className="...">
{children}
</main>
Focus Trap in Modals¶
Modals trap focus to prevent tabbing outside:
// AnimatedModal.tsx or custom implementation
useEffect(() => {
if (open) {
// Store previously focused element
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus first focusable element in modal
const focusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
focusable?.focus();
}
return () => {
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}, [open]);
Focus Indicators¶
All interactive elements have visible focus indicators:
// Tailwind classes for focus states
className =
'focus:outline-none focus:ring-2 focus:ring-[#76B900] focus:ring-offset-2 focus:ring-offset-[#1A1A1A]';
Screen Reader Support¶
ARIA Roles and States¶
// Dialog/Modal
<div
role="dialog"
aria-modal="true"
aria-label="Add alert rule"
// or aria-labelledby="modal-title"
>
<h2 id="modal-title">Add Alert Rule</h2>
...
</div>
// Live region for announcements
<div aria-live="polite" aria-atomic="true" className="sr-only">
{statusMessage}
</div>
// Busy state during loading
<div aria-busy={isLoading}>
{isLoading ? <Spinner /> : <Content />}
</div>
Dynamic Content Updates¶
// Announce changes to screen readers
<div aria-live="assertive" aria-atomic="true">
{criticalAlert && `Critical alert: ${criticalAlert.message}`}
</div>
Color Contrast Ratios¶
The design system uses WCAG-compliant colors defined in tailwind.config.js:
// tailwind.config.js - WCAG 2.1 AA compliant colors
colors: {
// Risk colors - 4.5:1 contrast on 10% opacity backgrounds
risk: {
low: '#76B900', // Green - good contrast on dark
medium: '#FFB800', // Amber - good contrast on dark
high: '#FFCDD2', // Light coral for 4.5:1 contrast
critical: '#FFE0E0', // Very light pink for critical badges
},
// Text colors - 4.5:1 minimum contrast on gray-700
text: {
primary: '#FFFFFF',
secondary: '#B0B0B0', // 5.17:1 contrast on gray-700
muted: '#919191', // 4.81:1 contrast on #222222
},
// Gray scale - adjusted for proper contrast
gray: {
500: '#9A9A9A', // 4.7:1 contrast on gray-700
400: '#B0B0B0', // 5.17:1 contrast on gray-700
},
}
Contrast requirements:
- Normal text (< 18pt): 4.5:1 minimum
- Large text (>= 18pt or 14pt bold): 3:1 minimum
- UI components and graphical objects: 3:1 minimum
Testing Contrast¶
# Check contrast in development
# Use browser DevTools > Lighthouse > Accessibility
# Or axe DevTools extension
# Automated E2E test
# frontend/tests/e2e/specs/accessibility.spec.ts
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
Testing Accessibility¶
Automated Testing with axe-core¶
The project uses @axe-core/playwright for automated WCAG compliance testing.
Running Tests¶
# Run accessibility tests
cd frontend && npx playwright test accessibility
# Run with specific browser
cd frontend && npx playwright test accessibility --project=chromium
Test Configuration¶
// frontend/tests/e2e/specs/accessibility.spec.ts
const WCAG_AA_TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa'];
async function runA11yCheck(page) {
return new AxeBuilder({ page }).withTags(WCAG_AA_TAGS).analyze();
}
test('dashboard page has no accessibility violations', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const results = await runA11yCheck(page);
expect(results.violations).toEqual([]);
});
Tested Pages and Features¶
| Page/Feature | Test Coverage |
|---|---|
| Dashboard | Full page scan, risk card focus, camera grid |
| Timeline | Page scan, filter controls, search input |
| Settings | Tab navigation, all tab panels |
| Alert Rules | Add/delete modals, table accessibility |
| Zone Editor | Canvas navigation, keyboard interaction |
| Modals | Focus trap, ARIA attributes, Escape key |
| Keyboard Navigation | Skip link, main navigation, list navigation |
| Color Contrast | Dashboard contrast, all critical UI elements |
| Forms | Label associations, validation errors |
Manual Testing Checklist¶
Use this checklist before releasing accessibility-impacting changes:
Keyboard Navigation¶
- [ ] Can navigate to all interactive elements using Tab
- [ ] Focus order follows visual layout
- [ ] Focus indicator is clearly visible
- [ ] Can activate all buttons/links with Enter/Space
- [ ] Can close modals with Escape key
- [ ] Skip link works and is visible on focus
- [ ] Keyboard shortcuts work (
?,Cmd+K,g + letter)
Screen Reader Testing¶
Test with at least one of:
- VoiceOver (macOS):
Cmd + F5to enable - NVDA (Windows): Free download from nvaccess.org
-
Orca (Linux): Pre-installed on many distributions
-
[ ] Page titles are announced on navigation
- [ ] Interactive elements have accessible names
- [ ] Form fields have associated labels
- [ ] Error messages are announced
- [ ] Modal content is announced when opened
- [ ] Live regions announce dynamic content
Visual Testing¶
- [ ] Content readable at 200% zoom
- [ ] UI usable at 320px viewport width
- [ ] Color is not the only means of conveying information
- [ ] Focus indicators visible in all color modes
- [ ] Animations respect
prefers-reduced-motion
Browser-Specific Considerations¶
Firefox Contrast Rendering¶
Firefox calculates color contrast with slightly different anti-aliasing than Chromium. The test suite includes a filter for Firefox-specific edge cases:
// frontend/tests/e2e/specs/accessibility.spec.ts
function filterFirefoxContrastViolations(violations, browserName) {
if (browserName !== 'firefox') return violations;
return violations.filter((violation) => {
if (violation.id !== 'color-contrast') return true;
// Filter 4.4-4.49 range (within 0.1 of 4.5 threshold)
const allNodesNearThreshold = violation.nodes.every((node) => {
const match = node.any[0]?.message?.match(/contrast of (\d+\.\d+)/);
if (match) {
const ratio = parseFloat(match[1]);
return ratio >= 4.4 && ratio < 4.5;
}
return false;
});
return !allNodesNearThreshold;
});
}
Adding New Accessible Components¶
When creating new components, follow this checklist:
1. Semantic HTML First¶
// Good - semantic HTML
<nav aria-label="Main navigation">
<ul>
<li><a href="/dashboard">Dashboard</a></li>
</ul>
</nav>
// Avoid - div soup
<div className="nav">
<div className="nav-item" onClick={...}>Dashboard</div>
</div>
2. ARIA Labels for Custom Components¶
// Custom toggle switch
<button
role="switch"
aria-checked={enabled}
aria-label={`${label}: ${enabled ? 'enabled' : 'disabled'}`}
onClick={() => setEnabled(!enabled)}
>
<span className={enabled ? 'on' : 'off'} />
</button>
3. Focus Management¶
// Trap focus in custom dropdown
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab' && isOpen) {
const focusable = dropdownRef.current?.querySelectorAll<HTMLElement>('button, [href], input');
const first = focusable?.[0];
const last = focusable?.[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
4. Test Coverage¶
Add accessibility tests for new components:
// ComponentName.test.tsx
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('supports keyboard navigation', () => {
render(<MyComponent />);
const button = screen.getByRole('button');
button.focus();
expect(button).toHaveFocus();
});
it('has accessible name', () => {
render(<MyComponent />);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
});
Resources¶
WCAG Guidelines¶
Testing Tools¶
- axe DevTools - Browser extension
- Lighthouse - Built into Chrome DevTools
- WAVE - Web accessibility evaluator
- Colour Contrast Analyzer - Desktop app
Screen Readers¶
Related Documentation¶
- Keyboard Patterns - Developer implementation guide
- Frontend Patterns - Component conventions
- User Keyboard Shortcuts - End-user reference
- User Accessibility Guide - End-user accessibility features
Accessibility is not an afterthought - it is a core requirement. Every PR that affects UI should be tested for accessibility compliance.