Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 058 / 100

Accessibility essentials for React devs, explained simply

Most issues come from a few habits.

Expona Admin6 min read

I've watched accessible React applications outperform their inaccessible counterparts not just on screen readers, but in search rankings, user retention, and team confidence. Yet most developers treat accessibility as an afterthought—or skip it entirely. The truth is, accessibility essentials for React devs boil down to a handful of habits you can adopt today, and they'll compound into better code for everyone.

The fastest way to wreck accessibility is reaching for a <div> when you need a <button> or <a>. I learned this the hard way on a trading platform where we'd wrapped every interactive element in divs with onClick handlers. Screen reader users had no way to navigate, keyboard users got stuck, and we'd introduced a maintenance nightmare.

Semantic HTML gives you accessibility for free. A real <button> is keyboard-focusable, announces itself to assistive tech, and works predictably. A div with onClick does none of that without custom ARIA and JavaScript cruft.

The rule: Use <button> for actions that trigger something in your app. Use <a> for navigation to another page. Everything else is a contentious edge case, but start with this and you'll be ahead of 90% of codebases.

tsx
// ❌ Bad: div pretending to be a button
<div onClick={() => submitForm()} className="btn">
  Submit
</div>

// ✅ Good: real button, free accessibility
<button onClick={() => submitForm()}>
  Submit
</button>

// ❌ Bad: button pretending to be a link
<button onClick={() => navigate('/dashboard')}>
  Go to dashboard
</button>

// ✅ Good: real link for navigation
<a href="/dashboard">
  Go to dashboard
</a>

// or with Next.js
<Link href="/dashboard">
  Go to dashboard
</Link>

I once spent three hours debugging why a modal's close button wasn't working with keyboard navigation. The reason? It was a div. Switched it to a button, problem solved instantly.

Labels for every input — Visible labels or aria-label

Placeholders are not labels. This bears repeating because I see it in production code constantly. A placeholder disappears when you start typing, leaves screen reader users blind, and looks professional for about five minutes before a real designer cries.

Every <input>, <select>, and <textarea> needs an associated label. You've got two solid approaches: explicit labels or aria-label.

Explicit labels are best. Connect them via the htmlFor attribute matching the input's id. This enlarges the clickable area (nice for mobile), makes it obvious visually, and works universally.

tsx
// ✅ Good: explicit label
<label htmlFor="email">
  Email address
</label>
<input id="email" type="email" />

// ✅ Also good: label wrapped around input
<label>
  Email address
  <input type="email" />
</label>

// ❌ Bad: placeholder instead of label
<input type="email" placeholder="Enter your email" />

// ⚠️ Acceptable in limited cases: aria-label for icon-only buttons
<button aria-label="Close dialog">
  <X size={20} />
</button>

I worked on an e-commerce site where form errors were announced to sighted users but invisible to screen reader users because we'd skipped labels entirely. Adding proper labels took an hour; fixing the downstream bugs took days.

If your label is hidden (like an icon-only button), use aria-label. But make sure the text inside the aria-label describes the action, not the icon. "Close dialog" beats "X".

Keyboard everything — If it works with a mouse, it must work with Tab and Enter

Power users live on the keyboard. Developers using screen readers live on the keyboard. If your feature only works with a mouse, it's broken for real people doing real work.

The baseline: Tab moves focus forward, Shift+Tab backward, Enter activates focused buttons, and arrow keys navigate lists or menus. Implement these and you've covered most use cases.

I've built complex modals, dropdowns, and carousels. The ones that felt solid always started with keyboard support baked in, not bolted on afterward. It's easier that way.

tsx
// ✅ Good: custom dropdown with keyboard support
function Dropdown({ options, onSelect }) {
  const [open, setOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(0);

  const handleKeyDown = (e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      setOpen(!open);
    }
    if (e.key === 'ArrowDown' && open) {
      e.preventDefault();
      setFocusedIndex((i) => Math.min(i + 1, options.length - 1));
    }
    if (e.key === 'ArrowUp' && open) {
      e.preventDefault();
      setFocusedIndex((i) => Math.max(i - 1, 0));
    }
    if (e.key === 'Escape') {
      setOpen(false);
    }
  };

  return (
    <div>
      <button onClick={() => setOpen(!open)} onKeyDown={handleKeyDown}>
        Select option
      </button>
      {open && (
        <ul role="listbox">
          {options.map((opt, idx) => (
            <li
              key={opt}
              role="option"
              tabIndex={focusedIndex === idx ? 0 : -1}
              onClick={() => onSelect(opt)}
              onKeyDown={handleKeyDown}
            >
              {opt}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Test yourself: open your app, hide your mouse, and navigate using only Tab, Enter, and arrow keys. If you get stuck, your users will too.

Test with tools — axe DevTools and eslint-plugin-jsx-a11y catch the easy ones

You don't need to be an accessibility expert to catch low-hanging fruit. Two tools do 80% of the work: axe DevTools and eslint-plugin-jsx-a11y.

axe DevTools is a browser extension (Chrome, Firefox, Edge) that scans your page and flags violations. Run it weekly on your major flows. It won't catch everything, but what it does find is usually a five-minute fix.

eslint-plugin-jsx-a11y catches mistakes at development time. Install it, add it to your ESLint config, and you'll get warnings for missing labels, invalid ARIA attributes, and inaccessible patterns before they hit production.

tsx
// .eslintrc.js
module.exports = {
  extends: ['next', 'plugin:jsx-a11y/recommended'],
  plugins: ['jsx-a11y'],
};

// Now this triggers a warning immediately
// ❌ Missing alt text
<img src="hero.jpg" />

// ✅ Fixed
<img src="hero.jpg" alt="Customer dashboard overview" />

On the ride-hailing platform, axe caught an issue where our map pins weren't labeled—users had no idea what they represented. Fixed in minutes because the tool pointed directly at the problem.

Tools aren't perfect. They won't test user experience or catch every keyboard trap. But they'll eliminate 50+ common violations with zero effort. Do it.

Common mistakes

Relying on color alone to convey meaning. If you're saying "required fields are red," someone colorblind just got confused. Add an asterisk or text label too.

Forgetting focus styles. When you remove the default browser outline with outline: none, you'd better replace it with something visible. Keyboard users need to know where they are. Use :focus-visible for a modern approach.

Setting tabIndex to large numbers. tabIndex={1}, tabIndex={2}, etc. breaks natural tab order. Use tabIndex only when you need to pull something out of flow. Prefer semantic HTML so tab order sorts itself.

Using ARIA to fix bad HTML. role="button" on a div is a band-aid. Use a real button. ARIA is for edge cases, not crutches.

Auto-focusing on page load. Some frameworks love to auto-focus the first input. This confuses screen reader users and feels aggressive. Let users navigate naturally.

Key takeaways

  • Semantic HTML (<button>, <a>, <label>) handles most accessibility needs without extra work.
  • Every input needs a label. Placeholders don't count. Use htmlFor or aria-label.
  • Keyboard navigation is non-negotiable. Test by hiding your mouse and Tab through your app.
  • Run axe DevTools and eslint-plugin-jsx-a11y regularly to catch easy wins.
  • Accessibility isn't a feature to add later—it's a development habit that starts today.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles