accessibilityreactnext.js

Building Accessible Components for Screen Readers

Building Accessible Components for Screen Readers

Picture an NVDA user opening a modal with a form, filling it in, and clicking "Close." The modal disappears visually — display: none in CSS, everything looks fine. Except the element is still sitting in the DOM, focus never moved anywhere, and the screen reader keeps reading the content of the window it was just "closed." The user hears the contents of a form they just dismissed and has no idea where they are on the page now. This isn't a made-up example — it's one of the most common mistakes in components built "by eye," without understanding how accessibility actually works. In this article I want to go beyond a checklist of rules and show the mechanism behind them — plus a few real components where you can see exactly where the traps are.

How a screen reader actually "sees" a page

Before fixing components, it helps to know what a screen reader actually works with. It doesn't render the page the way a browser does — it operates on the accessibility tree, a structure the browser builds in parallel with the DOM. Every node in that tree has three core properties: a role (is it a button, a link, a heading, a form field), a name (what the user hears as its description), and a state (expanded, checked, disabled).

The key consequence: a screen reader doesn't see your CSS. display: none and visibility: hidden remove an element from the accessibility tree — that's a correct way to hide something. But simply moving an element off-screen (position: absolute; left: -9999px) or setting its color to transparent changes nothing in the accessibility tree — the element is still there and still gets read out. That's exactly what went wrong in the modal example above.

The second pillar is DOM order. In browse mode, a screen reader reads the page in the order elements appear in the tree — regardless of how you arranged them visually with flex-direction: row-reverse or grid-template-areas. If your layout makes visual sense but the source order is arbitrary, a blind user gets the page "in random order."

Semantics as the first line of defense

The cheapest way to get accessibility right is to use the correct HTML element instead of reinventing its behavior from scratch. Look at the difference:

jsx

// Bad – looks like a button, but isn't one
const SaveButton = ({ onSave }) => (
<div className="btn" onClick={onSave}>
Save changes
</div>
);

This div has serious gaps that aren't obvious at a glance:

  • It's not focusable – you can't reach it with the Tab key.
  • It doesn't respond to the keyboard – Enter and Space do nothing, because React's onClick only listens for mouse/touch.
  • It has no button role – a screen reader reads it as plain text, with no indication it can be "activated."
  • It has no disabled state – you can't just set disabled, you'd have to simulate it manually.

jsx

// Good – you get all of the above for free
const SaveButton = ({ onSave, isSaving }) => (
<button type="button" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving…" : "Save changes"}
</button>
);

The same principle applies to <nav> instead of <div className="nav">, a <label> tied to a field via htmlFor instead of a placeholder pretending to be a label, or <ul>/<li> for lists instead of a stack of <div>s. Semantic HTML isn't "nicer" — it literally generates a different, richer structure in the accessibility tree.

ARIA: when it helps, and when it hurts

The WAI-ARIA spec opens with a rule worth memorizing word for word: "No ARIA is better than Bad ARIA." ARIA attributes don't add behavior — they only override what a screen reader reports to the user. If you promise something the component can't actually deliver, you're worse off than if you'd added nothing at all.

jsx

// Bad – ARIA promises a button, but nothing else backs it up
const DeleteIcon = ({ onDelete }) => (
<span role="button" aria-label="Delete item" onClick={onDelete}>
🗑
</span>
);

This code tells the screen reader "this is a button" — but it adds no keyboard support and no tabIndex, so a keyboard user can never reach it. That's worse than no role at all, because it gives the impression that the feature exists when it's physically unreachable.

jsx

// Good – just use a native button
const DeleteIcon = ({ onDelete }) => (
<button type="button" onClick={onDelete} aria-label="Delete item">
<span aria-hidden="true">🗑</span>
</button>
);

Notice the aria-hidden="true" on the emoji — without it, some screen readers try to read out the Unicode character's name ("wastebasket"), which sounds absurd right after the aria-label was already announced. aria-label completely replaces the visible content in what the user hears — if an element already has readable text, aria-labelledby pointing to that text is usually the better choice, so you're not maintaining two independent descriptions that can drift apart over time.

A practical example: an accessible accordion

A simple button doesn't show much. Let's look at a component where you genuinely have to manage state and ARIA relationships deliberately — an accordion, the expandable section pattern common in FAQs or settings panels.

jsx

import { useId, useState } from "react";
const AccordionItem = ({ title, children, defaultOpen = false }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="accordion-item">
<h3 className="accordion-header">
<button
type="button"
className="accordion-trigger"
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => setIsOpen((open) => !open)}
>
{title}
<span className="accordion-icon" aria-hidden="true">
{isOpen ? "−" : "+"}
</span>
</button>
</h3>
<div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
};
export default AccordionItem;

A few decisions in this code aren't accidental:

  • aria-expanded reports the current state – without it, a screen reader user just hears "button," with no way to know whether the section is open.
  • aria-controls links the button to the panel it controls – some screen readers announce this relationship, making navigation easier.
  • hidden (rather than hiding via CSS alone) guarantees that closed content is genuinely removed from the accessibility tree and the Tab order, so focus can't land in invisible content.
  • The <h3> wrapping the button keeps the heading hierarchy intact – screen reader users very often navigate by headings, jumping between sections with the H key.
  • The +/ icon has aria-hidden="true", because the state is already communicated via aria-expanded – without this, a screen reader would announce it twice, in a confusing form.

Live regions and dynamic messages

Another common problem: something changes on the page without a reload, and the screen reader never notices, because there's no reason for it to re-read a fragment the user isn't currently exploring. A classic example is a validation error that appears dynamically after leaving a field:

jsx

import { useState } from "react";
const EmailField = () => {
const [error, setError] = useState("");
const handleBlur = (event) => {
const value = event.target.value;
setError(value.includes("@") ? "" : "Enter a valid email address.");
};
return (
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
onBlur={handleBlur}
/>
<span id="email-error" role="alert" className="field-error">
{error}
</span>
</div>
);
};
export default EmailField;

role="alert" makes the element behave like an implicit aria-live="assertive" region – when its content changes, the screen reader interrupts whatever it's currently reading and announces the new message immediately. That's appropriate for errors that need urgent attention, but don't overuse it for less pressing updates (like "draft saved") – aria-live="polite" is a better fit there, since it waits for the user to finish their current action instead of interrupting them. aria-describedby additionally ties the error message to the field, so the screen reader reads it together with the label every time the user returns to that input – not just at the moment the error first appeared.

Focus management on navigation in Next.js

This trap is specific to single-page apps, including the Next.js App Router. On a classic page transition (full reload), the browser resets focus to <body>, and the screen reader announces the new document title – the user knows they landed on a new page. With client-side navigation, none of that happens automatically: focus stays on whatever link was clicked (often somewhere in the navigation, outside the new content), and the screen reader gets no signal that anything changed at all.

jsx

"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
const RouteAnnouncer = ({ pageTitle }) => {
const pathname = usePathname();
const headingRef = useRef(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">
{pageTitle}
</h1>
);
};
export default RouteAnnouncer;

The trick is tabIndex={-1} – normally headings aren't focusable, but this value lets you move focus onto them programmatically (.focus()) without adding them to the natural Tab order. After every path change (pathname), focus returns to the new page's heading, so the screen reader announces its title – exactly as it would on a traditional reload. It's one component worth adding to your layout once and never thinking about again.

How to actually test accessibility

Automated tools like axe-core or eslint-plugin-jsx-a11y are worth enabling from day one of a project – they catch the obvious mistakes (missing alt, poor contrast, missing labels) before they reach production. But be aware of their limits: according to research from Deque Systems, automated tools realistically catch about 30–40% of accessibility issues. The rest requires manual checking, because it's about meaning and context that a machine can't evaluate – whether reading order actually makes sense, whether an error message actually explains what to do, whether a focus trap in a modal genuinely doesn't let the user escape.

A concrete, repeatable process that catches the most real-world issues:

  • Put the mouse away and go through the whole flow with just the keyboard – Tab, Shift+Tab, Enter, Space, Escape, arrow keys wherever that's natural (e.g. in a menu). If at any point you don't know where focus is, that's already a bug.
  • Turn on VoiceOver (macOS: Cmd+F5) or NVDA (Windows, free) and go through the same flow with your eyes closed. Only this reveals whether the order, names, and states actually make sense out loud, not just on paper.
  • Test dynamic messages separately – trigger a form error, change route, open a modal – and check whether the screen reader actually announced something, not just whether the element has the right attribute in the code.

Conclusion

Accessible components aren't a checklist of attributes bolted on at the end – they're a consequence of how you model state and structure from the very start. Three things worth taking away from this article: first, semantic HTML gives you focusability, keyboard support, and a correct role for free – ARIA should fill in what HTML can't express, not replace it in components that could just as well be a native element. Second, changes in state and content have to be actively announced – aria-live, role="alert", and focus management after navigation aren't extras, they're the condition for a dynamic app to be usable at all without sight. Third, no automated tool replaces walking through your own interface with your eyes closed – it's the fastest way to see exactly where your "accessible" component actually loses the user.