Five React components I rebuild accessibly on every project
Buttons, dialogs, disclosures, tabs and form fields: the components teams most often get wrong, and the small patterns that make them work for everyone.
- Accessibility
- React
- Front end
- 2 min read
Most accessibility bugs in React apps come from five components. Get these right once, put them in your design system, and a large share of your audit findings disappear.
1. Button
The most common bug I find is a div with an onClick. It looks like a button, but it can't be focused, doesn't respond to Enter or Space, and screen readers announce it as plain text.
// ✗ Looks like a button, isn't one
<div className="btn" onClick={save}>Save</div>
// ✓ Free keyboard support, focus and semantics
<button type="button" onClick={save}>Save</button>Icon-only buttons need a name: <button aria-label="Close dialog">.
2. Dialog
A modal must move focus into itself when it opens, keep focus inside while it's open, close on Esc, and return focus to the button that opened it. The native <dialog> element with showModal() now does most of this for you.
const ref = useRef<HTMLDialogElement>(null);
<button onClick={() => ref.current?.showModal()}>Edit profile</button>
<dialog ref={ref} aria-labelledby="edit-title">
<h2 id="edit-title">Edit profile</h2>
{/* … */}
<button onClick={() => ref.current?.close()}>Done</button>
</dialog>3. Disclosure (accordion)
The trigger is a real button with aria-expanded, and it controls a region it points to with aria-controls.
<button aria-expanded={open} aria-controls="faq-1" onClick={() => setOpen(!open)}>
What does it cost?
</button>
<div id="faq-1" hidden={!open}>…</div>4. Tabs
Tabs need the full pattern or none of it: role="tablist", role="tab" with aria-selected, arrow-key navigation between tabs, and only the active tab in the tab order. If you can't build all of that, a row of links or a disclosure is better than half a tab widget.
5. Form field
Every input gets a visible <label>, not a placeholder. Errors are linked to the input with aria-describedby and announced when they appear.
<label htmlFor="email">Email</label>
<input id="email" type="email" aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} />
{error && <p id="email-error">{error}</p>}Test with a keyboard first
Before you reach for a screen reader, unplug your mouse and use the component. Most of these bugs show up within ten seconds.
Want a review of your component library? Email me.