DOMAIN:FRONTEND:ACCESSIBILITY¶
OWNER: julian (domain), floris (Team Alpha implementation), floor (Team Beta implementation)
UPDATED: 2026-03-24
SCOPE: all client projects — WCAG 2.1 AA compliance is MANDATORY
LEGAL_CONTEXT: European Accessibility Act (EAA) enforced since June 2025. Fines up to EUR 500,000.
ALSO_CHECK: domains/accessibility/index.md (EN 301 549, EAA, compliance frameworks)
COMPLIANCE_TARGET¶
STANDARD: WCAG 2.1 Level AA
LEGAL_BASIS: European Accessibility Act (Directive 2019/882), EN 301 549 v3.2.1
SCOPE: all products and services offered to EU consumers (GE's entire client base)
ENFORCEMENT: national market surveillance authorities. Netherlands: Dutch Human Rights Institute.
NOTE: WCAG 2.2 adds 9 new criteria. GE targets 2.1 AA as baseline. 2.2 AA is recommended for new projects.
SEMANTIC_HTML¶
RULE: use semantic HTML elements as the primary accessibility mechanism. ARIA is a supplement, not a replacement.
ELEMENTS¶
// GOOD: semantic elements communicate meaning to assistive tech
<header>...</header>
<nav aria-label="Main navigation">...</nav>
<main>...</main>
<aside>...</aside>
<footer>...</footer>
<article>...</article>
<section aria-labelledby="section-title">
<h2 id="section-title">Section Title</h2>
</section>
// BAD: divs with roles recreating native semantics
<div role="navigation">...</div>
<div role="main">...</div>
<div role="banner">...</div>
HEADINGS¶
RULE: one <h1> per page. Heading hierarchy must not skip levels (h1 -> h3 is invalid).
RULE: headings describe content structure. Never use heading tags for visual sizing.
// GOOD: proper hierarchy
<h1>Dashboard</h1>
<h2>Recent Orders</h2>
<h3>Order #1234</h3>
<h2>Statistics</h2>
<h3>Revenue</h3>
<h3>Users</h3>
// BAD: skipped levels, used for visual sizing
<h1>Dashboard</h1>
<h4>Recent Orders</h4> {/* Skipped h2, h3 */}
<h2>Some section styled smaller than h4</h2>
INTERACTIVE_ELEMENTS¶
RULE: use <button> for actions, <a> for navigation. Never use <div onClick> or <span onClick>.
// GOOD: native button — keyboard accessible, focusable, announced as button
<button onClick={handleSave}>Save</button>
// GOOD: link navigates to a URL
<Link href="/settings">Settings</Link>
// BAD: div as button — not focusable, not announced, no keyboard support
<div onClick={handleSave} className="cursor-pointer">Save</div>
// BAD: button used for navigation
<button onClick={() => router.push("/settings")}>Settings</button>
IF element performs action THEN use <button>
IF element navigates to URL THEN use <a> or <Link>
IF element toggles state THEN use <button> with aria-pressed or <input type="checkbox">
IF element selects from options THEN use <select> or Radix Select
ARIA_PATTERNS¶
FIRST_RULE_OF_ARIA¶
"No ARIA is better than bad ARIA." — W3C WAI
USE_ARIA_WHEN: native HTML cannot express the semantics (custom widgets, dynamic content, state).
DO_NOT_USE_ARIA: to duplicate what native HTML already provides.
// BAD: redundant ARIA on native element
<button role="button" aria-label="Save">Save</button>
// GOOD: ARIA adds information that HTML cannot express
<button aria-expanded={isOpen} aria-controls="menu-panel">
Menu
</button>
<div id="menu-panel" role="menu" hidden={!isOpen}>
{/* menu items */}
</div>
LIVE_REGIONS¶
USE_WHEN: content updates dynamically without user interaction (notifications, chat, live data).
// Polite: announced after current speech completes
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
// Assertive: interrupts current speech (errors, urgent notifications only)
<div role="alert" aria-live="assertive">
{errorMessage}
</div>
// Status: polite announcement for status updates
<div role="status" aria-live="polite">
{searchResults.length} results found
</div>
RULE: use polite by default. assertive only for errors and time-critical alerts.
ANTI_PATTERN: marking a large content area as live region — screen reader announces every change.
COMMON_ARIA_ATTRIBUTES¶
// Labeling
aria-label="Close dialog" // When no visible text label exists
aria-labelledby="heading-id" // Points to visible heading as label
aria-describedby="help-text-id" // Points to supplementary description
// State
aria-expanded={true|false} // Collapsible sections, dropdowns
aria-selected={true|false} // Tabs, list items
aria-checked={true|false|"mixed"} // Checkboxes, toggles
aria-pressed={true|false} // Toggle buttons
aria-current="page" // Current page in navigation
aria-disabled={true} // Disabled but still visible/focusable
aria-busy={true} // Loading state
aria-invalid={true} // Form validation error
// Relationships
aria-controls="panel-id" // This element controls that element
aria-owns="child-id" // Logical parent when DOM order differs
aria-haspopup="dialog"|"menu" // This element triggers a popup
KEYBOARD_NAVIGATION¶
RULE: every interactive element must be operable with keyboard only.
RULE: focus order must match visual order (no tabindex > 0).
FOCUS_MANAGEMENT¶
"use client";
import { useRef, useEffect } from "react";
export function Modal({ open, onClose, title, children }: ModalProps) {
const closeRef = useRef<HTMLButtonElement>(null);
// Move focus to close button when modal opens
useEffect(() => {
if (open) closeRef.current?.focus();
}, [open]);
// Trap focus inside modal (Radix Dialog does this automatically)
return (
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">{title}</h2>
{children}
<button ref={closeRef} onClick={onClose}>
Close
</button>
</div>
);
}
NOTE: if using shadcn/ui Dialog (Radix), focus trapping and management is handled automatically. Do NOT implement custom focus trapping with shadcn dialogs.
KEYBOARD_PATTERNS¶
| Component | Expected Keyboard Behavior |
|---|---|
| Button | Enter/Space activates |
| Link | Enter activates |
| Dropdown | Arrow keys navigate, Enter selects, Escape closes |
| Dialog/Modal | Tab cycles within, Escape closes, focus trapped |
| Tabs | Arrow keys switch tabs, Tab moves to panel |
| Menu | Arrow keys navigate, Enter selects, Escape closes |
| Accordion | Enter/Space toggles section |
| Checkbox | Space toggles |
| Radio group | Arrow keys move selection |
NOTE: Radix UI primitives implement all these patterns. Use Radix/shadcn instead of building custom.
SKIP_LINKS¶
RULE: every page must have a "skip to main content" link as the first focusable element.
// components/layouts/skip-link.tsx
export function SkipLink() {
return (
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-background focus:px-4 focus:py-2 focus:shadow-lg"
>
Skip to main content
</a>
);
}
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SkipLink />
<header>...</header>
<main id="main-content">{children}</main>
</body>
</html>
);
}
FOCUS_VISIBLE¶
RULE: never remove focus outlines globally. Use focus-visible for keyboard-only focus styles.
// GOOD: visible focus ring for keyboard users, hidden for mouse users
<button className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
Click me
</button>
// BAD: removes focus outline for everyone
<button className="outline-none focus:outline-none">Click me</button>
COLOR_AND_CONTRAST¶
CONTRAST_RATIOS¶
WCAG_AA_NORMAL_TEXT: 4.5:1 contrast ratio (text < 18pt or < 14pt bold)
WCAG_AA_LARGE_TEXT: 3:1 contrast ratio (text >= 18pt or >= 14pt bold)
WCAG_AA_UI_COMPONENTS: 3:1 contrast ratio (borders, icons, focus indicators)
/* GOOD: sufficient contrast */
--color-foreground: oklch(0.145 0 0); /* Near black on white = high contrast */
--color-muted-foreground: oklch(0.4 0 0); /* Dark gray on white >= 4.5:1 */
/* BAD: insufficient contrast */
--color-muted-foreground: oklch(0.7 0 0); /* Light gray on white < 4.5:1 */
TOOL: use Chrome DevTools contrast checker (inspect element > color picker shows ratio).
TOOL: WebAIM Contrast Checker (https://webaim.org/resources/contrastchecker/).
COLOR_NOT_SOLE_INDICATOR¶
RULE: never use color alone to convey information.
// BAD: only color indicates error
<input className={hasError ? "border-red-500" : "border-gray-300"} />
// GOOD: color + icon + text
<div>
<input
className={hasError ? "border-destructive" : "border-input"}
aria-invalid={hasError}
aria-describedby={hasError ? "error-msg" : undefined}
/>
{hasError && (
<p id="error-msg" className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
Email is required
</p>
)}
</div>
REDUCED_MOTION¶
RULE: respect prefers-reduced-motion for users who experience motion sickness or vestibular disorders.
/* Tailwind: motion-safe and motion-reduce variants */
.animate-bounce { animation: bounce 1s infinite; }
@media (prefers-reduced-motion: reduce) {
.animate-bounce { animation: none; }
}
// Tailwind utility classes
<div className="motion-safe:animate-bounce motion-reduce:animate-none">
Bouncing content
</div>
// Or use the hook
"use client";
import { useMediaQuery } from "@/lib/hooks/use-media-query";
export function AnimatedComponent() {
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
return (
<div style={{ transition: prefersReducedMotion ? "none" : "transform 0.3s ease" }}>
Content
</div>
);
}
FORMS_ACCESSIBILITY¶
LABELS¶
RULE: every form input must have an associated label. No exceptions.
// GOOD: visible label with htmlFor
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" />
// GOOD: wrapping label (implicit association)
<label>
Email address
<input type="email" name="email" />
</label>
// GOOD: visually hidden label (when design requires it)
<label htmlFor="search" className="sr-only">Search products</label>
<input id="search" type="search" placeholder="Search products..." />
// BAD: no label at all
<input type="email" placeholder="Email" />
ANTI_PATTERN: using placeholder as the label. Placeholder disappears on focus and has poor contrast.
ERROR_MESSAGES¶
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : "email-hint"}
/>
<p id="email-hint" className="text-sm text-muted-foreground">
We will never share your email.
</p>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
FORM_GROUPS¶
// Related inputs grouped with fieldset/legend
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" name="street" />
<label htmlFor="city">City</label>
<input id="city" name="city" />
</fieldset>
SCREEN_READER_TESTING¶
TOOLS¶
PRIMARY: NVDA (Windows, free), VoiceOver (macOS/iOS, built-in), TalkBack (Android, built-in)
AUTOMATED: axe-core (catches ~57% of WCAG issues), eslint-plugin-jsx-a11y (catches common JSX issues)
MANUAL: Accessibility Insights for Web (Chrome extension, guided assessment)
AXE_CORE_IN_TESTS¶
// Playwright + axe-core integration
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("homepage has no accessibility violations", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(results.violations).toEqual([]);
});
test("dashboard form is accessible", async ({ page }) => {
await page.goto("/dashboard/settings");
const results = await new AxeBuilder({ page })
.include("#settings-form")
.withTags(["wcag2a", "wcag2aa"])
.analyze();
expect(results.violations).toEqual([]);
});
ESLINT_JSX_A11Y¶
// .eslintrc.json
{
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/label-has-associated-control": "error"
}
}
STORYBOOK_A11Y¶
// Component story with accessibility checks
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./button";
const meta: Meta<typeof Button> = {
component: Button,
parameters: {
a11y: {
config: {
rules: [{ id: "color-contrast", enabled: true }],
},
},
},
};
export default meta;
export const Primary: StoryObj<typeof Button> = {
args: { children: "Click me", variant: "default" },
};
IMAGES_AND_MEDIA¶
ALT_TEXT¶
RULE: every <img> must have an alt attribute.
IF image is informative THEN provide descriptive alt text.
IF image is decorative THEN use alt="" (empty string, not omitted).
IF image is complex (chart, diagram) THEN provide extended description.
// Informative image
<Image src="/product.webp" alt="Red leather handbag with brass buckle" width={400} height={300} />
// Decorative image
<Image src="/divider.svg" alt="" width={800} height={2} aria-hidden="true" />
// Complex image with extended description
<figure>
<Image src="/revenue-chart.png" alt="Revenue chart showing 25% growth in Q4" width={600} height={400} />
<figcaption>
Revenue grew from EUR 1.2M in Q3 to EUR 1.5M in Q4 2025, driven by
new client onboarding in the Netherlands and Germany.
</figcaption>
</figure>
ANTI_PATTERN: alt text starting with "image of" or "picture of" — screen readers already announce it as an image.
TESTING_CHECKLIST¶
AUTOMATED (CI pipeline):
- [ ] axe-core Playwright tests pass on all pages
- [ ] eslint-plugin-jsx-a11y reports zero errors
- [ ] Lighthouse accessibility score >= 95
MANUAL (per release):
- [ ] keyboard-only navigation works for all workflows
- [ ] screen reader (NVDA or VoiceOver) announces content correctly
- [ ] focus order is logical
- [ ] skip link works
- [ ] color contrast meets AA ratios
- [ ] content readable at 200% zoom
- [ ] reduced motion respected
SELF_CHECK¶
BEFORE_EVERY_COMPONENT:
- [ ] am I using semantic HTML before ARIA?
- [ ] does every interactive element work with keyboard?
- [ ] does every form input have a label?
- [ ] are error messages associated with inputs via aria-describedby?
- [ ] is color not the sole indicator of state?
- [ ] do images have appropriate alt text?
- [ ] are animations respecting prefers-reduced-motion?
- [ ] is focus visible for keyboard users?
- [ ] does the component pass axe-core checks?