EthicsComputerCourses
Join training +91 90532 10052

Accessibility in React

React · Lesson 35 of 41 Advanced 8 min read Updated

Accessibility (often shortened to a11y) means people can use your app however they use a computer: with a keyboard instead of a mouse, with a screen reader that speaks the page aloud, with zoomed text or with a shaky hand on a small phone. Government and many company projects in India must meet accessibility standards, and an accessible app is simply a better app for everyone.

React renders ordinary HTML, so most accessibility comes from writing good HTML in your JSX. But single-page apps add their own problems: content changes without a page load, focus gets lost when elements disappear, and custom widgets need keyboard support. This lesson covers those React-specific parts. For the full background (WCAG, screen readers, Indian law), see the Web Accessibility tutorial.

Start with the right elements

The first rule is to use the HTML element that already does the job. A <button> can be focused with Tab, pressed with Enter or Space, and is announced as "button". A <div onClick> can do none of this unless you add it all yourself. In JSX a few attribute names differ from HTML, but accessibility attributes do not:

HTMLJSXNote
for="email"htmlFor="email"Connects a <label> to its input
tabindex="0"tabIndex={0}Camel case, number value
aria-expanded="true"aria-expanded={open}All aria-* names stay exactly as in HTML
role="tablist"role="tablist"Unchanged

Boolean values such as aria-expanded={open} are written to the page as the strings "true" and "false", which is what screen readers expect. Give every image an alt (empty alt="" for decorative images), every input a label, and every icon-only button an aria-label.

Accessible form errors

A sighted user sees a red border; a screen-reader user needs the error connected to the field. Three attributes do this: aria-invalid marks the field as wrong, aria-describedby points to the id of the error text so it is read with the field, and moving focus to the first wrong field takes the user straight to the problem. useId creates unique ids, so the component can be used twice on one page:

React JSXTry it yourself
import { useId, useRef, useState } from "react";
import { createRoot } from "react-dom/client";

function Field({ label, error, inputRef, ...inputProps }) {
  const id = useId();
  const errorId = `${id}-error`;
  return (
    <p>
      <label htmlFor={id}>{label}</label>{" "}
      <input id={id} ref={inputRef} aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined} {...inputProps} />
      {error && <span id={errorId} style={{ color: "#b91c1c" }}> {error}</span>}
    </p>
  );
}

function AdmissionForm() {
  const [errors, setErrors] = useState({});
  const nameRef = useRef(null);
  const phoneRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const found = {};
    if (String(data.get("name")).trim().length < 3) found.name = "Enter your full name.";
    if (!/^[6-9]\d{9}$/.test(String(data.get("phone")))) found.phone = "Enter a 10-digit mobile number.";
    setErrors(found);
    const firstBad = found.name ? nameRef : found.phone ? phoneRef : null;
    if (firstBad) {
      firstBad.current.focus();
      console.log(`Focus moved to: ${firstBad.current.labels[0].textContent}`);
    } else {
      console.log("Form sent");
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <Field label="Student name" name="name" inputRef={nameRef} error={errors.name} />
      <Field label="Mobile" name="phone" inputMode="numeric" inputRef={phoneRef} error={errors.phone} />
      <button>Apply</button>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<AdmissionForm />);

Type a name such as "Isha Nair" and leave the mobile empty, then press "Apply". The error appears and the console prints:

Output
Focus moved to: Mobile

A screen reader now announces something like "Mobile, invalid entry, edit text, Enter a 10-digit mobile number." noValidate turns off the browser's own popups so your messages are used consistently. For long forms, many teams also show an error summary at the top with links to each field.

Dialogs and focus

Focus is where keyboard input goes. When you open something, focus should move into it; when you close it, focus should return to where the user was. The native <dialog> element, opened with showModal(), does most of this for you: it moves focus inside, keeps the rest of the page from being reached, closes with Escape, and returns focus to the button that opened it. In React you call showModal() through a ref:

React JSXTry it yourself
import { useRef, useState } from "react";
import { createRoot } from "react-dom/client";

function DeleteOrder({ orderNo }) {
  const dialogRef = useRef(null);
  const cancelRef = useRef(null);
  const [status, setStatus] = useState("");

  function open() {
    dialogRef.current.showModal();
    cancelRef.current.focus(); // start on the safer choice
  }

  function close(result) {
    dialogRef.current.close();
    setStatus(result);
    setTimeout(() => console.log(`Focus is back on: ${document.activeElement.textContent}`), 0);
  }

  return (
    <>
      <button onClick={open}>Delete order {orderNo}</button>
      <dialog ref={dialogRef} aria-labelledby="confirm-title" onClose={() => console.log("Dialog closed")}>
        <h2 id="confirm-title">Delete order {orderNo}?</h2>
        <p>This cannot be undone.</p>
        <button onClick={() => close("Order deleted")}>Delete</button>{" "}
        <button ref={cancelRef} onClick={() => close("Kept")}>Cancel</button>
      </dialog>
      <p role="status">{status}</p>
    </>
  );
}

createRoot(document.getElementById("root")).render(<DeleteOrder orderNo="JP-4471" />);

Open the dialog and press "Cancel". The console prints:

Output
Dialog closed
Focus is back on: Delete order JP-4471

By default showModal() focuses the first focusable element inside, here "Delete". For a destructive action it is kinder to start on "Cancel", so open moves focus there with a second ref. (React's autoFocus prop does not help here: React focuses the element when it first mounts, and this dialog is mounted, closed, from the start.) aria-labelledby gives the dialog its name from the heading. The paragraph with role="status" is a live region: when its text changes, screen readers announce it politely without moving focus. A live region must already be on the page before its text changes, so render the empty element and fill it later, as here. Use role="alert" only for urgent messages, because it interrupts the user.

Keyboard support for custom widgets

When no native element exists, as with tabs, you must add keyboard behaviour yourself. The usual pattern for tabs is a roving tabindex: only the selected tab has tabIndex={0}, so Tab moves into the tab list once, and the arrow keys move between tabs:

React JSXTry it yourself
import { useRef, useState } from "react";
import { createRoot } from "react-dom/client";

const tabs = ["Overview", "Syllabus", "Fees"];

function CourseTabs() {
  const [active, setActive] = useState(0);
  const tabRefs = useRef([]);

  function handleKeyDown(e) {
    let next = active;
    if (e.key === "ArrowRight") next = (active + 1) % tabs.length;
    else if (e.key === "ArrowLeft") next = (active - 1 + tabs.length) % tabs.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = tabs.length - 1;
    else return;
    e.preventDefault();
    setActive(next);
    tabRefs.current[next].focus();
    console.log(`${e.key}: ${tabs[next]}`);
  }

  return (
    <div>
      <div role="tablist" aria-label="BCA course" onKeyDown={handleKeyDown}>
        {tabs.map((name, i) => (
          <button key={name} role="tab" id={`tab-${i}`} aria-selected={i === active} aria-controls={`panel-${i}`}
            tabIndex={i === active ? 0 : -1} ref={(el) => { tabRefs.current[i] = el; }}
            onClick={() => setActive(i)}>
            {name}
          </button>
        ))}
      </div>
      <div role="tabpanel" id={`panel-${active}`} aria-labelledby={`tab-${active}`} tabIndex={0}>
        {active === 0 && "Three-year degree at a college in Lucknow."}
        {active === 1 && "C, Java, DBMS, Web Development, Python."}
        {active === 2 && "₹38,000 per year."}
      </div>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<CourseTabs />);

Click "Overview" to focus it, then press the right arrow twice and Home:

Output
ArrowRight: Syllabus
ArrowRight: Fees
Home: Overview

The ref callback stores each button in an array, so the handler can move real browser focus, not only the highlight. Libraries such as React Aria and Radix UI ship tested versions of tabs, menus and comboboxes; for complex widgets, using one of them is often safer than writing your own.

Route changes and page titles

In a normal website every link loads a new page, and screen readers announce its title. In a single-page app the URL changes but nothing is announced, and focus may stay on a link that no longer exists. After navigation, update the title and move focus to the new page's main heading. React 19 lets any component render a <title>, which React places in the document head:

React JSX
function OrdersPage() {
  const headingRef = useRef(null);
  useEffect(() => {
    headingRef.current.focus(); // tell screen-reader users the page changed
  }, []);
  return (
    <main>
      <title>My orders | Kirana Express</title>
      <h1 ref={headingRef} tabIndex={-1}>My orders</h1>
    </main>
  );
}

tabIndex={-1} lets code focus the heading without adding it to the Tab order. See Single-Page Apps & Route Changes for other approaches.

Common mistakes

  • Clickable divs and spans. They cannot be focused or pressed with the keyboard. Use <button> for actions and <a>/<Link> for navigation.
  • Placeholder instead of a label. Placeholders vanish when typing starts and are often not read. Use a real <label>.
  • Losing focus when an element disappears. After deleting an item or closing a panel, move focus somewhere sensible.
  • Adding a live region at the same moment as its text. Many screen readers ignore it; render the empty region first.
  • Removing focus outlines. outline: none without a visible replacement leaves keyboard users lost.
  • ARIA instead of HTML. role="button" on a div still needs keyboard code; a real button needs none.

Key points

  • Most accessibility is good HTML: real buttons, links, labels, headings and alt text.
  • In JSX use htmlFor and tabIndex; all aria-* attributes keep their HTML names.
  • Connect errors with aria-invalid and aria-describedby, and move focus to the first wrong field.
  • Open dialogs with <dialog> and showModal() through a ref; focus moves in and returns on close.
  • Live regions (role="status") announce updates; they must exist before their text changes.
  • Custom widgets need keyboard support, such as a roving tabindex for tabs.
  • After route changes, update the <title> and move focus to the new page's heading.

Exercise

Build an accessible "Notify me" form for an out-of-stock phone. It has one email input with a proper label, a submit button, and a role="status" paragraph that is always rendered. When the email does not contain "@", show an error connected with aria-describedby, set aria-invalid, and move focus to the input. When it is valid, clear the error and put "We will email you when it is back in stock." in the status paragraph.

Show solution
React JSX
import { useId, useRef, useState } from "react";
import { createRoot } from "react-dom/client";

function NotifyMe() {
  const id = useId();
  const inputRef = useRef(null);
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    const email = String(new FormData(e.currentTarget).get("email")).trim();
    if (!email.includes("@")) {
      setError("Enter an email address such as name@example.com.");
      setStatus("");
      inputRef.current.focus();
      return;
    }
    setError("");
    setStatus("We will email you when it is back in stock.");
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <label htmlFor={id}>Email</label>{" "}
      <input id={id} name="email" type="email" ref={inputRef}
        aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-err` : undefined} />
      <button>Notify me</button>
      {error && <p id={`${id}-err`} style={{ color: "#b91c1c" }}>{error}</p>}
      <p role="status">{status}</p>
    </form>
  );
}

createRoot(document.getElementById("root")).render(<NotifyMe />);

The status paragraph is rendered from the start, so screen readers announce the success message when its text appears. The error is linked to the input, and focus goes straight to the field that needs fixing.

Quick Quiz

1. How do you write the HTML attribute for="city" on a label in JSX?

2. What does aria-describedby={errorId} on an input achieve?

3. Why should a live region be rendered before its message changes?

4. With a roving tabindex, which tab has tabIndex={0}?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call