
Accessibility for Developers Who Don't Know Where to Start
Try navigating your own app using only the Tab key, no mouse. If you get stuck, lose track of where focus is, or can't reach a button at all, that's not a hypothetical edge case — that's how a meaningful chunk of real users, and every screen reader user, experiences your app every day.
Accessibility usually gets pushed to "later" because it sounds like a specialty. It isn't. Most of the fixes that matter are small, mechanical, and take minutes once you know what to look for.
Why this keeps getting skipped
Accessibility work tends to get deprioritized for a predictable reason: it's invisible to the people making the roadmap decisions. Nobody on the team uses a screen reader, so nobody notices the <div onClick> that a keyboard user can't reach. The bug doesn't show up in a demo. It shows up when a real user can't complete checkout and just leaves.
The good news: you don't need to become an accessibility specialist to fix the majority of real-world issues. Four categories cover most of what actually breaks apps for real users:
- Semantic HTML — using the right element for the job
- Keyboard navigation — everything reachable and operable without a mouse
- Focus management — the user always knows where they are
- Color and contrast — content readable without relying on color alone
Semantic HTML: the fix that costs nothing
This is the highest-leverage change you can make, because it's often just swapping one tag for another.
// ❌ A div styled to look like a button gets none of the built-in behavior
<div className="button" onClick={handleSubmit}>
Submit
</div>
// ✅ A real button is keyboard-focusable, triggers on Enter/Space, and announces correctly
<button className="button" onClick={handleSubmit}>
Submit
</button>
A <div> with an onClick looks identical visually, but it doesn't receive keyboard focus, doesn't respond to Enter or Space, and gets no role announced to screen readers. You'd have to manually reimplement all of that with tabIndex, onKeyDown, and role="button" — or just use <button> and get it for free.
The same logic applies everywhere:
| Instead of | Use |
|---|---|
<div onClick> | <button> |
<div> for a list of items | <ul> / <li> |
Styled <span> as a heading | <h1>–<h6> |
<div> wrapping form inputs | <label> with htmlFor |
Keyboard navigation: the test you can run right now
Every interactive element on your page should be reachable and usable with Tab, Enter, and Space alone. Test it:
// ❌ Custom dropdown, mouse-only — Tab skips right past it
function Dropdown({ options }: { options: string[] }) {
const [open, setOpen] = useState(false)
return (
<div onClick={() => setOpen(!open)}>
Select an option
{open &&
options.map((o) => (
<div key={o} onClick={() => select(o)}>
{o}
</div>
))}
</div>
)
}
// ✅ Focusable trigger, keyboard-operable options
function Dropdown({ options }: { options: string[] }) {
const [open, setOpen] = useState(false)
return (
<div>
<button aria-expanded={open} aria-haspopup="listbox" onClick={() => setOpen(!open)}>
Select an option
</button>
{open && (
<ul role="listbox">
{options.map((o) => (
<li
key={o}
role="option"
tabIndex={0}
onClick={() => select(o)}
onKeyDown={(e) => e.key === 'Enter' && select(o)}
>
{o}
</li>
))}
</ul>
)}
</div>
)
}
aria-expanded and aria-haspopup tell a screen reader what the button does before it's even activated. That context matters as much as the interaction working at all.
Focus management: where does the user land?
Single-page apps break a browser default that users rely on: when content changes, focus should move somewhere sensible. Routing between pages without managing focus leaves the screen reader user stuck announcing whatever was focused before the navigation happened.
// ✅ Move focus to the new page's heading on route change
function PageLayout({ title, children }: { title: string; children: React.ReactNode }) {
const headingRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
headingRef.current?.focus()
}, [title])
return (
<>
<h1 ref={headingRef} tabIndex={-1}>
{title}
</h1>
{children}
</>
)
}
tabIndex={-1} makes the heading programmatically focusable without adding it to the normal Tab order — exactly what you want for this case.
Modals need the same care: trap focus inside while open, and return it to the triggering element when closed. Libraries like Radix UI or Headless UI handle this correctly out of the box — worth using instead of building modals from scratch.
Color and contrast
Never rely on color alone to convey information.
// ❌ Red text is the only signal that this field has an error
<input className="border-red-500" />
<span className="text-red-500">Invalid email</span>
// ✅ Icon and text convey the same information color-blind users can't get from red alone
<input aria-invalid="true" aria-describedby="email-error" className="border-red-500" />
<span id="email-error" className="text-red-500 flex items-center gap-1">
<AlertIcon aria-hidden="true" />
Invalid email address
</span>
aria-invalid and aria-describedby also connect the input to its error message for screen readers, which the color alone never did.
Run your color palette through a contrast checker. WCAG AA requires a 4.5:1 ratio for normal text — light gray text on a white background almost never passes this, no matter how good it looks on your monitor.
Common mistakes
- ❌ Adding
aria-labelto fix everything. ARIA attributes patch missing semantics — they don't replace using the right HTML element in the first place. Reach for semantic HTML first, ARIA second. - ❌ Testing only with a mouse. If you never unplug your mouse and try Tab-only navigation, you'll never catch the keyboard traps you've built.
- ❌ Hiding focus outlines with
outline: none. This is one of the most common accessibility regressions — removing the focus ring makes keyboard navigation unusable because users lose all visual feedback about where they are. - ❌ Treating accessibility as a post-launch cleanup task. Retrofitting semantic HTML into a component library built entirely on
<div>s takes far longer than building it right the first time.
Best practices
- Run axe DevTools or Lighthouse's accessibility audit on every PR that touches UI. It won't catch everything, but it catches the mechanical issues instantly.
- Keep focus outlines visible, and style them if the default looks bad — don't remove them.
- Test your top three user flows with a keyboard only, once a sprint. Checkout, sign-up, and search are usually the highest-stakes flows to get right.
- Use a real screen reader occasionally — VoiceOver on Mac (Cmd+F5) is built in and free. Ten minutes of real usage teaches you more than any article.
What to do next
Unplug your mouse right now and try to complete your app's main user flow using only the keyboard. Every place you get stuck is a real bug, not a nice-to-have. Fix those first — they're usually a missing <button>, a missing focus trap, or an outline: none that shouldn't be there.
That single exercise will surface more real accessibility issues in ten minutes than most audits catch in a week.