The upstream community guide is silent on accessibility; this overlay is not, because a control a user cannot reach is a control that does not work. REACT-4 states it plainly: accessibility is correctness — the first value the core guide opens with (correctness > performance > DX), applied to the human at the edge instead of the type at the boundary. This chapter is not compliance theater — not a WCAG checkbox chased the week before audit — but whether the feature functions for everyone who arrives at it: by keyboard, by screen reader, by switch device, by a screen too small or eyes that parse contrast differently. A11y bugs are correctness bugs, so they are caught the way every correctness bug is: by the linter (7.4) for the static half and by tests (7.7) for the rest. Neither is optional, and an inaccessible control fails review like any other broken behaviour.
function EmailField({onSubmit}: {onSubmit: (email: string) => void}) {
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const problem = validateEmail(email);
setError(problem);
if (problem) inputRef.current?.focus(); // 7.2: focus moves to the field to fix
else onSubmit(email);
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="email">Email address</label> {/* 7.1, 7.3: real label, wired by htmlFor */}
<p id="email-hint">We use this only to send your receipt.</p>
<input
ref={inputRef} id="email" type="email" value={email}
onChange={(e) => setEmail(e.target.value)}
aria-describedby={error ? 'email-hint email-error' : 'email-hint'}
aria-invalid={error != null} // 7.3: state announced, not just recolored (7.6)
/>
{error && <p id="email-error" role="alert">{error}</p>} {/* 7.6: text, not color alone */}
<button type="submit">Save email</button> {/* 7.1, 7.2: native, keyboard-reachable */}
</form>
);
}A <label htmlFor="email"> binds to the input's id, so a click on the label focuses the field and a screen reader announces the name (7.1, 7.3). The hint and the error are wired through aria-describedby, which lists both ids when the field is invalid (7.3); aria-invalid flags the error state programmatically rather than by color (7.3, 7.6), and role="alert" makes the message announce the moment it appears. On a failed submit, focus moves to the offending input (7.2) instead of stranding the user wherever they were. The submit control is a native <button> inside a <form>, so Enter and Space work and no ARIA is needed (7.1). Nothing here is decoration — every attribute carries function a sighted mouse user gets implicitly.
Reasoning, step by step:
- Native elements arrive with behaviour, state, and a role the platform already implements and keeps correct across browsers and assistive tech. A
<button>is focusable, fires on Enter and Space, exposes thebuttonrole, and reflectsdisabled— for free. A<nav>, a<main>, a<label>, a<ul>each carry meaning the accessibility tree reads directly. Rebuilding any of that on a<div>means reimplementing focus, keyboard handling, and roles by hand, and you will get an edge case wrong. - ARIA exists to fill the gaps native HTML leaves, not to paper a role onto the wrong element. The first rule of ARIA is its own warning: if a native element with the semantics you need exists, use it instead of repurposing a
<div>withrole. The W3C states it as "no ARIA is better than bad ARIA" — a wrong or stale ARIA attribute actively lies to a screen reader, which is worse than a plain element with no semantics at all. So<div role="button" onClick>is a defect on sight: it has no keyboard handler, no focusability, no Space/Enter, and it announces a role it cannot back up.
Worked example:
<div role="button" onClick={onClose}>Close</div> {/* Banned: no keyboard, no focus, a lie to AT */}
<button type="button" onClick={onClose}>Close</button> {/* Good: behaviour, role, keyboard for free */}Enforcement: review rejects role on an element a native tag would cover; jsx-a11y (7.4) flags interactive handlers on non-interactive elements.
Reasoning, step by step:
- A pointer is one input device among many; keyboard, switch, and screen-reader navigation all traverse the page through focus. If a control cannot be reached by Tab and operated by Enter or Space, it does not exist for those users — the feature is as broken as if it threw. The check is mechanical and non-negotiable: tab through every new piece of UI before requesting review, confirming each control is reachable, operable, and that focus order follows reading order. Native elements (7.1) give you this for free; custom widgets must earn it with
tabIndex, key handlers, and the right role. - The focus ring is the keyboard user's cursor — the only signal of where they are.
outline: nonewith nothing in its place deletes that cursor; it removes a function, not a style, and is banned on the same footing as deleting the control. If the default ring clashes with the design, replace it with a:focus-visiblestyle of equal or better clarity; never remove it outright. Focus must also be managed, not just preserved: a dialog traps focus on open and returns it to the trigger on close; a validation error moves focus to the field that failed (see the exemplar).
Worked example:
/* Banned: deletes the keyboard user's cursor */
button:focus { outline: none; }
/* Good: a clear, deliberate focus indicator */
button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }Enforcement: tab-through is a review gate for every new view; outline: none without a :focus-visible replacement is rejected; jsx-a11y flags positive tabIndex and missing key handlers.
Reasoning, step by step:
- An input without a programmatically associated label is anonymous to a screen reader — it announces "edit text, blank," and the user has no idea what to type. The label must be associated, not merely nearby: a visible
<label htmlFor={id}>bound to the input'sid, oraria-labelledbypointing at existing visible text. A placeholder is not a label: it vanishes the instant the user types, fails contrast in every browser default, is inconsistently announced, and leaves no prompt once a value is present. It is at most a supplementary example, never the name of the field. - Errors and hints must reach the same accessibility tree as the value. Wire help text and validation messages with
aria-describedby(it accepts a space-separated list of ids, so a hint and an error coexist, as in the exemplar), and mark the invalid state witharia-invalidso it is announced as state, not inferred from a red border (7.6). Put the error in a live region —role="alert"oraria-live="assertive"— so it is spoken the moment it renders, not only when the user navigates back to the field.
Worked example:
<input placeholder="Email" /> {/* Banned: placeholder is gone on focus, never the name */}
// Good: real label, described-by hint + error, invalid state exposed (full form in the exemplar)
<label htmlFor="e">Email</label>
<input id="e" aria-describedby="e-err" aria-invalid={hasError} />
{hasError && <p id="e-err" role="alert">Enter a valid email.</p>}Enforcement: jsx-a11y/label-has-associated-control requires a bound label; review forbids placeholder-as-label and requires aria-describedby + aria-invalid on validated fields.
Reasoning, step by step:
- A large class of accessibility defects is static and therefore machine-detectable: an
<img>with noalt, a<label>bound to nothing, anonClickon a<div>with no keyboard handler, a positivetabIndex, an invalidaria-*attribute or value. Catching these in the editor is strictly cheaper than catching them in review or in production, so the overlay runseslint-plugin-jsx-a11ywith itsrecommendedconfig alongsideeslint-plugin-react-hooks(REACT-2). It belongs in the same correctness-justified tier as the hooks plugin: not a style preference but a lint that encodes the Rules of React's a11y obligations, and likeexhaustive-depsits findings are errors, fixed at the source rather than suppressed. - The plugin is a floor, not a ceiling, and the boundary is sharp: it verifies the static shape of the JSX — that attributes exist and are well-formed — and is blind to everything dynamic. It cannot tab through your UI, cannot tell whether focus returns to the trigger when a dialog closes, cannot judge whether an
altstring is meaningful or whether color is the only signal. Linting catches the static half; the human (7.2) and the test suite (7.7) catch the flow. Passingjsx-a11yclean is the entry condition for review, never its conclusion.
Worked example:
// eslint.config.js (flat) — a11y lint sits beside the hooks lint, both correctness-tier
import jsxA11y from 'eslint-plugin-jsx-a11y';
import reactHooks from 'eslint-plugin-react-hooks';
export default [jsxA11y.flatConfigs.recommended, reactHooks.configs['recommended-latest']];Enforcement: jsx-a11y/recommended runs in CI with violations as errors; suppressions require a written justification in review, the same bar as silencing exhaustive-deps.
Reasoning, step by step:
- A screen reader cannot see a graphic; the text alternative is the only thing it can announce, so every non-text element must declare what it is — including declaring that it is nothing. An informative image needs an
altthat conveys its information as the content it replaces, not as "image of": a chart'saltstates the trend, an avatar'saltis the person's name. A decorative image — a divider, a flourish, an icon that merely repeats adjacent text — declares itself withalt=""(an explicit empty string, not a missing attribute), which removes it from the accessibility tree so the screen reader skips it cleanly. A missingaltis the bug: assistive tech then reads the file name aloud. - Icon-only controls are the most common silent failure. A button whose entire content is an SVG has no accessible name — it announces "button," nothing more. Give it an
aria-label(or visually hidden text) for the action, and mark the decorative glyph inside itaria-hidden. Time-based media carries the same obligation: video needs captions, audio needs a transcript, because the content is otherwise unreachable.
Worked example:
<img src="/chart.png" alt="Revenue rose 40% from Q1 to Q2" /> {/* informative: describe it */}
<img src="/divider.svg" alt="" /> {/* decorative: declare emptiness */}
<button aria-label="Delete row"><TrashIcon aria-hidden="true" /></button> {/* icon-only: named */}Enforcement: jsx-a11y/alt-text requires an alt decision on every image; review verifies icon-only controls have an aria-label and that alt strings are meaningful, not filenames.
Reasoning, step by step:
- Color carries no information to a user who cannot distinguish it — color-blind, low-vision, on a washed-out screen in sunlight, or listening through a screen reader that has no concept of red. So color must always be redundant: pair it with text, an icon, a shape, or a pattern that carries the same meaning on its own. A required field marked only by a red asterisk-colored label is invisible to those users; add the word "required" or an icon. An error shown only as a red border fails the same way — which is why 7.3 mandates
aria-invalidand a text message, so the state is announced and read, not merely tinted. A status that changes must announce the change (a live region), not just recolor a dot. - When color is used, it must clear the contrast bar so low-vision users can perceive it at all. Text and its background meet WCAG AA: a 4.5:1 ratio for normal text, 3:1 for large text and for the visual boundaries of UI components and meaningful graphics. This is a measurable threshold, checked with a contrast tool against the design tokens, not eyeballed. The two halves compose: meaning never rides on hue alone, and the hues you do use are perceivable.
Worked example:
// Banned: the only cue is color — invisible to a color-blind or screen-reader user
<span style={{color: 'red'}}>Failed</span>
// Good: icon + text carry the meaning; color is reinforcement, and contrast meets AA
<span className="status-error"><XIcon aria-hidden="true" /> Failed</span>Enforcement: review requires a non-color cue (text/icon/pattern) for every state; contrast is verified against AA (4.5:1 text) via a contrast checker on the tokens; state changes route through a live region (7.3).
Reasoning, step by step:
- Testing Library's role queries traverse the same accessibility tree a screen reader does, which makes them a proxy for reachability rather than a mere convenience.
getByRole('button', {name: 'Save email'})finds the control only if it exposes thebuttonrole and an accessible name — exactly the two things a screen reader needs and the two things the rules above provide. The contrapositive is the whole point: ifgetByRolecannot find an element, neither can assistive technology. A test written this way (the default mandated by chapter 06 §6.1) does not merely check behaviour; it asserts accessibility as a side effect, so a missing label or a<div>-button fails a test instead of slipping to production. - This closes the loop the other rules open. The linter (7.4) catches the static half; role-query tests catch the dynamic half the linter cannot see — that focus moved to the errored field, that the dialog trapped and restored focus, that the error landed in a live region and was announced. Because these assertions run on every push (chapter 06), an accessibility regression turns a build red the same day it is written, which is what it means to enforce a11y in tests rather than discover it in a quarterly audit. The audit finds what already shipped; the test refuses to let it ship.
Worked example:
it('focuses the email field and announces the error on invalid submit', async () => {
render(<EmailField onSubmit={vi.fn()} />);
const user = userEvent.setup(); // 06 §6.2: setup() once per test
await user.click(screen.getByRole('button', {name: 'Save email'})); // found only if accessible
expect(screen.getByRole('alert')).toHaveTextContent(/valid email/i); // announced (7.3)
expect(screen.getByRole('textbox', {name: 'Email address'})).toHaveFocus(); // managed (7.2)
});Enforcement: components ship with role-query tests per chapter 06 §6.1; a control unreachable by getByRole fails review; focus and live-region behaviour are asserted, not left to manual audit.
- Accessibility is correctness, the overlay's framing of the correctness value for the human at the edge: README.md (REACT-4).
- Native elements over
role-on-divand controlled inputs: 01-components-and-props.md; composition over a forest of boolean props: README.md (REACT-6). - Focus management in effects, cleanup, and the Rules of Hooks the focus-trap relies on: 02-hooks.md and README.md (REACT-2, REACT-5).
react-hook-form+zodresolvers, where the labels,aria-describedby, and error wiring of 7.3 live in real forms: 04-data-fetching-and-forms.md.- Role-based queries,
user-eventoverfireEvent, behaviour-first testing — the enforcement loop of 7.7 — and the assertion-as-correctness disciplinejsx-a11yjoins: 06-testing-react.md (§6.1) and core 11-testing.md.