accessibility-patterns
SKILL.md
Accessibility Patterns
Semantic HTML First
Use the correct HTML element before reaching for ARIA. Native elements provide keyboard behavior, focus management, and screen reader semantics for free:
| Need | Use | Not |
|---|---|---|
| Button | <button> |
<div onClick> |
| Link | <a href> |
<span onClick> |
| Navigation | <nav> |
<div class="nav"> |
| List | <ul> / <ol> |
<div> with divs |
| Heading | <h1>–<h6> |
<div class="title"> |
| Form field | <input> / <select> / <textarea> |
<div contenteditable> |
| Dialog | <dialog> |
<div class="modal"> |
The first rule of ARIA: don't use ARIA if a native HTML element does the job.
ARIA Essentials
When native elements aren't sufficient:
Roles
Add semantic meaning to generic elements:
<div role="tablist">
<button role="tab" aria-selected="true">Tab 1</button>
<button role="tab" aria-selected="false">Tab 2</button>
</div>
<div role="tabpanel">Content</div>
States and Properties
Communicate dynamic state to assistive technology:
<button aria-expanded="false" aria-controls="menu-content">Menu</button>
<div id="menu-content" hidden>...</div>
<input aria-invalid="true" aria-describedby="error-msg" />
<span id="error-msg">Email is required</span>
Live Regions
Announce dynamic content changes:
<div aria-live="polite" aria-atomic="true">3 results found</div>
<div role="alert">Your session is about to expire.</div>
aria-live="polite"— announces after current speech finishes.aria-live="assertive"orrole="alert"— interrupts immediately (use sparingly).
Labels
Every interactive element needs an accessible name:
<!-- Visible label (preferred) -->
<label for="email">Email</label>
<input id="email" type="email" />
<!-- aria-label for icon-only buttons -->
<button aria-label="Close dialog"><CloseIcon /></button>
<!-- aria-labelledby for complex labels -->
<h2 id="section-title">Billing</h2>
<form aria-labelledby="section-title">...</form>
Keyboard Navigation
Focus Order
- Ensure logical tab order follows visual layout. Don't use
tabindex> 0. - Use
tabindex="0"to make non-interactive elements focusable when necessary. - Use
tabindex="-1"to make elements programmatically focusable but not in the tab order.
Key Handlers
Implement expected keyboard patterns for custom widgets:
| Widget | Keys |
|---|---|
| Button | Enter, Space to activate |
| Menu | Arrow keys to navigate, Enter to select, Escape to close |
| Tabs | Arrow keys to switch, Enter/Space to activate |
| Dialog | Escape to close, trap focus inside |
| Combobox | Arrow keys to navigate, Enter to select, Escape to close |
function handleKeyDown(e: React.KeyboardEvent) {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusNextItem();
break;
case "ArrowUp":
e.preventDefault();
focusPreviousItem();
break;
case "Escape":
closeMenu();
break;
}
}
Focus Trapping
Modal dialogs must trap focus — Tab and Shift+Tab cycle only through elements inside the dialog:
function trapFocus(dialogRef: RefObject<HTMLDialogElement>) {
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const first = focusable?.[0];
const last = focusable?.[focusable.length - 1];
function handleTab(e: KeyboardEvent) {
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();
}
}
dialogRef.current?.addEventListener("keydown", handleTab);
}
Focus Restoration
When a dialog/popover closes, return focus to the element that triggered it:
const triggerRef = useRef<HTMLButtonElement>(null);
function openDialog() {
triggerRef.current = document.activeElement as HTMLButtonElement;
setOpen(true);
}
function closeDialog() {
setOpen(false);
triggerRef.current?.focus();
}
Color and Contrast
- Minimum contrast ratio: 4.5:1 for normal text, 3:1 for large text (WCAG AA).
- Never rely on color alone to convey meaning — add icons, text, or patterns.
- Test with forced-colors mode (Windows High Contrast).
Motion
Respect the user's motion preference:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
Forms
- Associate every input with a
<label>. - Group related fields with
<fieldset>and<legend>. - Display error messages inline, linked with
aria-describedby. - Use
aria-required="true"or therequiredattribute. - Mark invalid fields with
aria-invalid="true".
Images
<!-- Informative image -->
<img src="chart.png" alt="Sales increased 25% in Q4 2025" />
<!-- Decorative image -->
<img src="divider.svg" alt="" role="presentation" />
<!-- Complex image -->
<figure>
<img src="diagram.png" alt="System architecture overview" />
<figcaption>Request flow from client through API gateway to microservices</figcaption>
</figure>
Testing
- Keyboard-only: Tab through the entire page without a mouse.
- Screen reader: Test with VoiceOver (macOS), NVDA (Windows), or TalkBack (Android).
- Automated: Use
axe-core,eslint-plugin-jsx-a11y, or Lighthouse accessibility audit. - Manual: Zoom to 200%, use forced colors, test with
prefers-reduced-motion. - Testing Library: Use
getByRole,getByLabelText— if you can't query by role, the component may have an a11y issue.
Weekly Installs
1
Repository
grahamcrackers/skillsFirst Seen
Feb 28, 2026
Security Audits
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1