React a11y
21 composable Claude Code skills for senior React + UX engineering — component design, a11y, performance, forms, routing, marketing & docs patterns, testing, and interview craft.
npx -y skills add jaballer/react-claude-skills --skill react-a11yAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
React accessibility (WCAG 2.2 AA) — semantic HTML first, ARIA only when needed, focus management, keyboard navigation, screen reader support, color contrast, prefers-reduced-motion. Use when building any user-facing UI, forms, modals, menus, tabs, carousels, or when accessibility is in scope. Critical for Senior UX Engineer interview signal. Triggers on "a11y", "accessibility", "WCAG", "ARIA", "keyboard", "screen reader", "focus", "semantic".
SKILL.md
12.5 KB, as published. Nobody here has run it
react-a11y
Accessibility is the single highest-signal differentiator for a Senior UX Engineer. Most candidates ship working code; few ship code that works with a keyboard, screen reader, and reduced motion. Be the latter.
The "first rule of ARIA" — don't use it
Before reaching for role= or aria-*, ask: is there an HTML element that already does this? If yes, use it. The built-in element ships behavior (keyboard, focus, semantics) for free.
| Tempting to write | Use instead |
|---|---|
<div onClick={…}> | <button> |
<div role="button"> | <button> |
<a onClick={…}> (no href) | <button> |
<span> for a label | <label htmlFor="…"> |
| Custom dropdown div | <select> for simple cases |
<div role="dialog"> | <dialog> (with caveats) or robust modal pattern |
<div role="list"> | <ul> / <ol> |
<i className="icon"> for meaningful icons | Icon <svg> with <title> |
If you find yourself writing role="button", stop and use <button>. The interview reviewer will catch this in 2 seconds.
The pre-flight a11y triage (run before "done")
Before you say done, walk this list in order. 5 minutes.
1. Keyboard only — unplug the mouse
- Tab through every interactive element. Does focus follow visual order?
- Is the focused element always visible (visible focus ring)?
- Esc closes modals/popovers?
- Enter/Space activates buttons?
- Arrow keys navigate composite widgets (tabs, menus, listboxes)?
2. Names — what does a screen reader say?
For each interactive element, the accessible name should be unambiguous in isolation.
- Buttons: have text OR
aria-label. Never icon-only without a label. - Form inputs: have a
<label htmlFor>ORaria-labelledby.placeholderis not a label. - Links: text describes the destination. Never "click here" or "read more" without context.
- Images:
altdescribes intent (decorative →alt="").
3. Landmarks and headings
- One
<h1>per page (or per major region in a single-page app) - Heading levels don't skip (
h2→h3, noth2→h4) - Page has
<main>,<nav>,<footer>(and<header>if appropriate)
4. Contrast
- Text: 4.5:1 (3:1 for large text ≥ 24px / ≥ 19px bold)
- UI components (buttons, inputs borders) and focus indicators: 3:1
- Check both light AND dark mode if applicable
- Use
axeDevTools or the Lighthouse a11y audit
5. Motion
- Wrap non-essential animations in:
@media (prefers-reduced-motion: no-preference) { .animated { animation: slide 0.3s ease; } } - Or in JS:
const prefersReduced = useMediaQuery('(prefers-reduced-motion: reduce)'); - No flashing > 3 times/second (seizure risk)
Focus management patterns
Pattern: Focus on modal open, restore on close
function Modal({ open, onClose, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<Element | null>(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
dialogRef.current?.focus();
} else if (triggerRef.current instanceof HTMLElement) {
triggerRef.current.focus();
}
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
className="modal"
>
{children}
</div>,
document.body
);
}
Also needed in production: focus trap (Tab/Shift+Tab stays inside the dialog), scroll lock on body. Use focus-trap-react or roll your own — call this out in the walkthrough.
Pattern: Skip link
Always include for any layout with a nav:
<a href="#main" className="skip-link">Skip to main content</a>
…
<main id="main" tabIndex={-1}>…</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 0;
top: 0;
/* visible styles */
}
Pattern: Focus on SPA route change
The single highest-impact a11y move for any client-side router. When a user navigates in an SPA, the DOM updates but focus stays on the old link — screen reader users hear nothing change, keyboard users find themselves halfway down a navigation they've already left. Move focus to the new page's heading.
// In the page-level component (PostDetail, ProductPage, etc.)
function PostDetailPage() {
const { data } = useQuery(...);
const titleRef = useRef<HTMLHeadingElement>(null);
// Move focus to the title when the page's primary data arrives
useEffect(() => {
if (data && titleRef.current) titleRef.current.focus();
}, [data]);
return (
<article>
<h1
ref={titleRef}
tabIndex={-1} // makes a non-interactive element focusable
className="outline-none" // we still want :focus-visible, just not a click-focus ring
>
{data?.title}
</h1>
{/* ... */}
</article>
);
}
Why tabIndex={-1}: an <h1> isn't focusable by default. -1 makes it programmatically focusable without inserting it into the tab order. Screen readers will read the heading when focus lands on it.
Why outline-none (with :focus-visible left intact elsewhere): a click-triggered focus on a heading is visual noise. Keep the global :focus-visible ring for actual keyboard interactions.
The dependency on data (rather than route params) means focus moves when content actually arrives, not before the loading spinner.
For loaders/Suspense-based routers, a wrapper component that focuses on mount works:
function PageHeading({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLHeadingElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
return <h1 ref={ref} tabIndex={-1} className="outline-none">{children}</h1>;
}
Don't combine this with scroll-restoration logic — let the router's ScrollRestoration (React Router) handle scroll, and let focus handle screen-reader announcement separately.
Pattern: Roving tabindex (tabs, menus, listbox)
Only one element in the group is tabbable at a time; arrow keys move focus:
function Tabs({ items }: TabsProps) {
const [active, setActive] = useState(0);
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const onKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
if (e.key === 'ArrowRight') {
const next = (i + 1) % items.length;
setActive(next);
refs.current[next]?.focus();
} else if (e.key === 'ArrowLeft') {
const prev = (i - 1 + items.length) % items.length;
setActive(prev);
refs.current[prev]?.focus();
}
};
return (
<div role="tablist">
{items.map((item, i) => (
<button
key={item.id}
ref={el => { refs.current[i] = el; }}
role="tab"
aria-selected={active === i}
tabIndex={active === i ? 0 : -1}
onClick={() => setActive(i)}
onKeyDown={e => onKey(e, i)}
>
{item.label}
</button>
))}
</div>
);
}
Form a11y patterns
Every input has a real label
// Good
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Also good (label wraps)
<label>
Email
<input type="email" />
</label>
// Acceptable when label is visual elsewhere
<input aria-label="Search" type="search" />
Errors are programmatically associated
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<p id="email-error" role="alert">
{error}
</p>
)}
role="alert" announces the error when it appears. For multi-field forms, prefer a single <div role="alert" aria-live="polite"> summary at the top of the form so the user hears all errors at once.
Required fields
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
</label>
<input id="name" required aria-required="true" />
Or describe the convention once in form intro:
<p>Fields marked with <span aria-hidden="true">*</span> are required.</p>
Submit feedback (live region)
<div role="status" aria-live="polite">
{submitting && 'Submitting your request…'}
{submitted && 'Form submitted successfully.'}
</div>
Image and icon a11y
// Decorative — empty alt
<img src="/swirl.svg" alt="" />
// Meaningful image — describe intent, not pixels
<img src="/team.jpg" alt="The Netlify marketing team on stage at JamstackConf" />
// Icon with no accompanying text → label it
<button aria-label="Close">
<XIcon aria-hidden="true" />
</button>
// Icon next to text → hide from screen reader (text wins)
<button>
<DownloadIcon aria-hidden="true" />
Download
</button>
For decorative <svg> always include aria-hidden="true". For meaningful SVG include <title> as first child or use aria-label on the svg.
Color and contrast
- Never convey state by color alone — pair with icon, text, or shape
- Error text + red color + error icon — not just red
- Focus rings: use both color and a 2px+ ring; ensure 3:1 contrast against adjacent surfaces
- For text on images, use overlay/scrim to guarantee contrast
prefers-reduced-motion
For any motion: opacity, transform, transition, animation, scroll-triggered effect:
@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;
}
}
This is a Senior UX Engineer signal — most candidates forget it.
ARIA gotchas (the easy mistakes)
aria-labelon an element with visible text — the label overrides the text (screen reader users hear only the label)role="presentation"on something interactive — strips semanticsaria-hidden="true"on something focusable — screen reader users tab to nothingaria-expandedwithoutaria-controls(or vice versa) — partial wiringaria-live="polite"on a region that doesn't exist yet at announcement time — it must already be in the DOMtabindex> 0 — never use; it breaks tab order
Testing tools (mention in walkthrough)
- axe DevTools (browser extension): finds 30-40% of automatable issues
- Lighthouse Accessibility audit
- Keyboard test: hands off mouse, tab through everything
- Screen reader test: VoiceOver (Cmd+F5 on Mac), NVDA on Windows
- Color contrast: Chrome DevTools picker, axe, WebAIM contrast checker
"I ran axe and Lighthouse; both clean. I tabbed through with the keyboard and the focus order matches reading order. For the modal, I'd add a real focus trap library in production — for the time-boxed build I shipped Esc-to-close and initial focus."
Quick reference — WCAG 2.2 AA must-haves
- 1.1.1 Non-text content has alt text
- 1.3.1 Info and relationships preserved by semantic HTML
- 1.4.3 Text contrast 4.5:1
- 1.4.11 Non-text contrast 3:1 (UI components)
- 2.1.1 All functionality available from keyboard
- 2.4.3 Focus order is logical
- 2.4.7 Focus is visible
- 2.5.5 Target size 24×24px min (AA), 44×44px (AAA, but worth aiming for)
- 3.3.2 Form fields have labels
- 4.1.2 Custom controls have name, role, value
Narration phrases
- "I started with semantic HTML —
<button>and<form>give me keyboard handling and focus for free." - "For the modal, I capture focus on open and restore it on close. In production I'd add a real focus trap library."
- "I added a live region for the form errors so screen reader users hear them immediately."
- "I wrapped the slide animation in
prefers-reduced-motion: no-preference— not everyone wants motion." - "I used
aria-currentfor the active nav item — it's announced as 'current page' which is more meaningful than visual styling alone."