Samuel Osei, homeSay hi
Writing

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.