Accessibility — Rules and Conventions
1. Philosophy
- WCAG 2.2 AA minimum — Legal baseline for most jurisdictions. Aim for AAA where feasible.
- POUR principles — Perceivable, Operable, Understandable, Robust. Every decision maps here.
- Semantic HTML first — Native elements carry accessibility. ARIA only when HTML insufficient.
- Test with users — Automated tools catch ~30% of issues. Manual + screen reader testing required.
- Progressive enhancement — Core functionality works without JS. Enhance, don't depend.
2. Minimum Versions
| Technology |
Minimum Version |
| axe-core |
4.8+ |
| Node.js |
22+ |
| pnpm |
11+ |
3. WCAG 2.2 & POUR (Compact)
| Principle |
Guideline |
Key Success Criteria (AA) |
| Perceivable |
1.1 Non-text content |
1.1.1 Alt text for images |
|
1.3 Adaptable |
1.3.1 Info/relationships, 1.3.4 Orientation |
|
1.4 Distinguishable |
1.4.3 Contrast (4.5:1), 1.4.4 Resize text, 1.4.11 Non-text contrast (3:1) |
| Operable |
2.1 Keyboard |
2.1.1 Keyboard, 2.1.2 No trap, 2.1.4 Shortcuts |
|
2.4 Navigable |
2.4.3 Focus order, 2.4.7 Focus visible, 2.4.11 Focus not obscured |
|
2.5 Input modalities |
2.5.1 Pointer gestures, 2.5.2 Pointer cancellation, 2.5.3 Label in name, 2.5.8 Target size (24×24) |
| Understandable |
3.2 Predictable |
3.2.1 On focus, 3.2.2 On input |
|
3.3 Input assistance |
3.3.1 Error identification, 3.3.2 Labels/instructions, 3.3.3 Error suggestion |
| Robust |
4.1 Compatible |
4.1.2 Name/role/value, 4.1.3 Status messages |
Full WCAG 2.2: w3.org/WAI/WCAG22/quickref/
4. Color Contrast
Minimum ratios (WCAG 2.2 AA)
| Element |
Ratio |
AAA |
| Normal text (< 24px / < 18.5px bold) |
4.5:1 |
7:1 |
| Large text (≥ 24px / ≥ 18.5px bold) |
3:1 |
4.5:1 |
| UI components (borders, icons) |
3:1 |
— |
| Focus indicators |
3:1 |
— |
Tools
# Automated
pnpm add -D axe-core @axe-core/cli
npx axe http://localhost:3000
# Design-time
# Figma: Stark, Contrast plugins
# Browser: axe DevTools, WAVE
Rules
- Never rely on color alone — add text, icon, or pattern
- Test in forced-colors mode —
@media (forced-colors: active)
- High contrast mode — ensure visibility
5. Keyboard Navigation
Focus order
<!-- Logical DOM order = visual order -->
<header>...</header>
<main>...</main>
<aside>...</aside>
<footer>...</footer>
Focus trap (modals, drawers)
function trapFocus(element) {
const focusable = element.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
element.addEventListener("keydown", (e) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first?.focus();
}
Skip link (first focusable element)
<a href="#main" class="skip-link">Skip to main content</a>
.skip-link {
position: absolute;
top: -100%;
left: 50%;
transform: translateX(-50%);
padding: 1rem 2rem;
background: var(--color-primary);
color: white;
z-index: 10000;
}
.skip-link:focus {
top: 1rem;
}
Rules Navigation
- All interactive elements reachable — no
tabindex="-1" unless intentional
- No keyboard traps —
Esc closes modals/drawers
- Visible focus — never
outline: none without replacement
6. Focus Management
Focus restoration
function openModal(modal) {
const previousFocus = document.activeElement;
modal.show();
modal.addEventListener("hidden", () => previousFocus?.focus(), {
once: true,
});
}
Focus visible (CSS)
:focus-visible {
outline: 3px solid var(--color-focus, #0066cc);
outline-offset: 2px;
}
/* Remove default :focus only if :focus-visible provided */
:focus:not(:focus-visible) {
outline: none;
}
Focus not obscured (WCAG 2.2 2.4.11)
/* Ensure focus indicator not hidden by sticky headers */
:focus-visible {
scroll-margin-top: 100px; /* height of sticky header */
}
7. ARIA Essentials
Roles (essential only)
| Role |
Use |
Native alternative |
button |
Clickable non-button |
<button> |
dialog |
Modal dialog |
<dialog> |
alert |
Urgent message |
— |
status |
Non-urgent live update |
— |
navigation |
Nav section |
<nav> |
main |
Main content |
<main> |
region |
Labeled section |
<section aria-label="..."> |
tablist/tab/tabpanel |
Tabs |
— |
menu/menuitem |
Custom menu |
— |
listbox/option |
Custom select |
<select> |
combobox |
Autocomplete |
<input list="..."> |
tooltip |
Hover/focus label |
title attribute |
search / searchbox |
Search |
<form role="search"> |
States & Properties (essential)
| Attribute |
Values |
Use |
aria-expanded |
true/false |
Disclosure state |
aria-controls |
IDREF |
Element controlled |
aria-labelledby |
IDREF |
Accessible name from element |
aria-describedby |
IDREF |
Additional description |
aria-hidden |
true/false |
Hide from AT |
aria-live |
polite/assertive/off |
Live region |
aria-invalid |
true/false/grammar/spelling |
Form validation |
aria-required |
true/false |
Required field |
aria-disabled |
true/false |
Disabled (not disabled attr) |
aria-current |
page/step/location/date/time/true |
Current item |
aria-label |
string |
Name when no visible label |
aria-orientation |
vertical/horizontal |
Slider, tabs |
Rules ARiA
- Native HTML first —
<button> not <div role="button">
- No redundant ARIA —
<nav aria-label="Main"> not <nav role="navigation" aria-label="Main">
- ID references must exist —
aria-controls="id" requires id="id"
8. Accessible Name Computation
<!-- 1. aria-labelledby (highest priority) -->
<input aria-labelledby="label-id" />
<span id="label-id">Email</span>
<!-- 2. aria-label -->
<button aria-label="Close">×</button>
<!-- 3. <label for> -->
<label for="email">Email</label>
<input id="email" />
<!-- 4. Inner text (buttons, links) -->
<button>Submit</button>
<!-- 5. title attribute (fallback) -->
<input title="Search" />
9. Live Regions
<!-- Polite: queued, non-interrupting -->
<div aria-live="polite" aria-atomic="true">Item added to cart</div>
<!-- Assertive: immediate interrupt -->
<div aria-live="assertive">Error: Payment failed</div>
<!-- Status: implied polite -->
<div role="status">Loading...</div>
<!-- Log: sequential updates -->
<div role="log" aria-live="polite">Message 1<br />Message 2</div>
Rules Live Regions
aria-atomic="true" — read entire region on change
- Don't overuse — only for dynamic updates user must know
- Clear on dismiss — empty region after user acknowledges
10. Accessible Forms
Label association
<!-- Explicit (preferred) -->
<label for="email">Email</label>
<input type="email" id="email" required />
<!-- Implicit -->
<label>Email <input type="email" required /></label>
Validation & errors
<div>
<label for="email">Email <span aria-hidden="true">*</span></label>
<input
type="email"
id="email"
required
aria-describedby="email-error"
aria-invalid="true"
/>
<span id="email-error" role="alert">Invalid email format</span>
</div>
Rules Accessible Forms
- Every input has a label — visible or
aria-label/aria-labelledby
- Required marked —
required attr + visual indicator (*)
- Errors announced —
role="alert" or aria-live="assertive"
- Error linked —
aria-describedby on input
- Group related —
<fieldset> + <legend> for radios/checkboxes
11. Modals & Dialogs
<dialog id="modal" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Confirm</h2>
<p>Are you sure?</p>
<menu>
<button id="cancel">Cancel</button>
<button id="confirm" autofocus>Confirm</button>
</menu>
</dialog>
const modal = document.getElementById("modal");
const previousFocus = document.activeElement;
modal.showModal();
trapFocus(modal);
modal.addEventListener(
"close",
() => {
previousFocus?.focus();
},
{ once: true },
);
document.getElementById("cancel").onclick = () => modal.close();
document.getElementById("confirm").onclick = () => modal.close("confirmed");
Rules Modals & Dialog
<dialog> native — aria-modal="true", showModal() traps focus
- Focus trap —
Tab cycles within, Esc closes
- Return focus — to trigger element on close
- No scroll behind —
body { overflow: hidden } while open
12. Reduced Motion
@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;
}
}
// Respect in JS
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!prefersReduced) {
element.animate([...], { duration: 300 })
}
Rules Reduce Motion
- Respect
prefers-reduced-motion — disable non-essential motion
- Essential motion OK — loading spinners, progress indicators
- Provide pause/stop — for auto-playing carousels, videos
13. Testing
Automated (CI)
# axe-core
pnpm add -D axe-core @axe-core/cli
npx axe http://localhost:3000 --save --exit
# Lighthouse CI
pnpm add -D @lhci/cli
npx lhci autorun
Manual checklist (essential)
14. Methodology
Before using ANY accessibility pattern not documented in
this skill:
- MCP Context7 (priority):
context7_resolve-library-id +
context7_query-docs for axe-core, WAI-ARIA.
- Official docs: w3.org/WAI, developer.mozilla.org
— verify current specs.
- Project config:
package.json, CI configs
— verify against actual setup.
- HARD RULE: If not in this skill AND cannot be verified against
2 authoritative sources → DO NOT USE IT. Document as assumption or risk in
report to orchestrator.
15. Prohibitions
- ❌ Do not use
role="button" on <a> — use <button> for actions
- ❌ Do not use
aria-hidden="true" on focusable elements
- ❌ Do not use
tabindex > 0 — breaks natural order
- ❌ Do not skip heading levels (
h1 → h3)
- ❌ Do not use color alone for status (error/success)
- ❌ Do not autoplay audio/video > 3s without pause
- ❌ Do not use
title attribute as sole label
- ❌ Do not disable zoom (
user-scalable=no)
16. References
Note: For HTML conventions (semantics, landmarks),
see HTML
Note: For CSS conventions (contrast, focus, reduced motion),
see CSS
Note: For JavaScript conventions (focus management),
see JavaScript
Note: For Component Design (APG patterns),
see Component Design
Last updated: 2026-08