Skip to main content
    Back to blog
    DevelopmentPublished on October 24, 2026

    Accessibility in React: IDs, Focus on Route Change and Announcements

    useId for field relationships, focus management in SPAs, an announcement provider, restoring focus after deletion, proven libraries and the jsx-a11y linter

    Accessibility in React: IDs, Focus on Route Change and Announcements

    Part 7 of 8 in the "Accessible Web in Practice" series. Previous part: ARIA and Dynamic Content: When Attributes Help and When They Hurt.

    React renders regular DOM, so everything from the earlier parts applies directly. But single-page apps have their own traps: unique ids, focus during navigation, announcing changes. Here they are, with ready-to-use code.

    Accessibility in React

    Syntax details

    // In JSX it's htmlFor instead of for, and aria-* attributes are written as is
    <label htmlFor="email">Email address</label>
    <input id="email" type="email" autoComplete="email" aria-describedby="email-hint" />
    

    Don't add <div> wrappers where they break semantics: a <ul> must contain only <li> elements. To group without extra DOM, use fragments <>...</>.

    Unique ids: useId

    htmlFor, aria-describedby and aria-labelledby need unique ids. If a component renders several times on a page, a hard-coded id breaks the connection. Since React 18 there's useId:

    import { useId } from 'react';
    
    function TextField({ label, hint, error, ...props }) {
      const id = useId();
      const hintId = `${id}-hint`;
      const errorId = `${id}-error`;
    
      const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(' ') || undefined;
    
      return (
        <div className="field">
          <label htmlFor={id}>{label}</label>
          {hint && <p id={hintId} className="hint">{hint}</p>}
          <input
            id={id}
            aria-invalid={error ? true : undefined}
            aria-describedby={describedBy}
            {...props}
          />
          {error && <p id={errorId} className="error">{error}</p>}
        </div>
      );
    }
    
    // Usage
    <TextField label="Email address" type="email" autoComplete="email"
               hint="We'll send the confirmation here" error={errors.email} />
    

    Focus on route change

    When navigating between pages in an SPA, the browser doesn't reload, and focus stays on the clicked link — or gets lost. A screen reader user never learns that the page changed. A common fix is to move focus to the new page's heading and update document.title:

    import { useEffect, useRef } from 'react';
    import { useLocation } from 'react-router-dom';
    
    function PageHeading({ children, title }) {
      const ref = useRef(null);
      const { pathname } = useLocation();
    
      useEffect(() => {
        document.title = `${title} — Agama Labs`;
        ref.current?.focus();
      }, [pathname, title]);
    
      return (
        <h1 ref={ref} tabIndex={-1} className="page-heading">
          {children}
        </h1>
      );
    }
    
    /* The heading receives focus programmatically — its outline can be removed */
    .page-heading:focus { outline: none; }
    

    The Next.js App Router announces route changes to screen readers by default through a built-in announcer (it reads document.title, falling back to the first h1), which makes a unique title on every page especially important there (Next.js: Accessibility).

    An announcement hook

    import { createContext, useCallback, useContext, useState } from 'react';
    
    const AnnouncerContext = createContext(() => {});
    
    export function AnnouncerProvider({ children }) {
      const [message, setMessage] = useState('');
    
      const announce = useCallback((text) => {
        setMessage('');
        setTimeout(() => setMessage(text), 100);
      }, []);
    
      return (
        <AnnouncerContext.Provider value={announce}>
          {children}
          {/* The region is always rendered; only its text changes */}
          <div role="status" aria-live="polite" className="visually-hidden">
            {message}
          </div>
        </AnnouncerContext.Provider>
      );
    }
    
    export const useAnnounce = () => useContext(AnnouncerContext);
    
    // In a component
    function AddToCart({ product }) {
      const announce = useAnnounce();
      return (
        <button type="button" onClick={async () => {
          await addToCart(product.id);
          announce(`${product.name} added to cart`);
        }}>
          Add to cart
        </button>
      );
    }
    

    Returning focus after deletion

    If a user deletes a list item with a button inside that item, the button disappears along with focus, and focus "falls" to the top of the page. Move it to a neighboring item or to the list heading:

    function onDelete(index) {
      removeItem(index);
      // after render: the next item, else the previous one, else the heading
      requestAnimationFrame(() => {
        const next = itemRefs.current[index] ?? itemRefs.current[index - 1] ?? listHeadingRef.current;
        next?.focus();
      });
    }
    

    Don't reinvent complex components

    A combobox with autocomplete, a date picker, menus, tabs, drag-and-drop — making these accessible from scratch is very hard. There are libraries where that work is done and tested with screen readers:

    • React Aria (Adobe) — unstyled hooks and components with the most thorough accessibility work, including mobile screen readers;
    • Radix UI — unstyled primitives; shadcn/ui is built on them;
    • Headless UI — from the makers of Tailwind CSS.

    Linting

    eslint-plugin-jsx-a11y catches mistakes as you type: img without alt, onClick on a div with no keyboard handler, invalid ARIA attributes.

    npm install --save-dev eslint-plugin-jsx-a11y
    
    // eslint.config.js (flat config)
    import jsxA11y from 'eslint-plugin-jsx-a11y';
    
    export default [
      jsxA11y.flatConfigs.recommended,
      // ...the rest of your config
    ];
    

    The "Accessible Web in Practice" series

    1. Web Accessibility: Who It Really Serves and Where to Start
    2. Semantic HTML and Alt Text: The Foundation of an Accessible Website
    3. Accessible Forms: Labels, Hints, Errors and Autofill
    4. Keyboard and Focus: Building a Website You Can Use Without a Mouse
    5. Contrast, Motion and Button Size: Visual Accessibility
    6. ARIA and Dynamic Content: When Attributes Help and When They Hurt
    7. Accessibility in React: IDs, Focus on Route Change and Announcements — you are here
    8. How to Test Accessibility: Automation, Manual Checks and a Checklist

    Next part: How to Test Accessibility: Automation, Manual Checks and a Checklist

    Agama Labs builds websites and web applications. Want to check how accessible your site is? Write to us: hello@agamalabs.com

    Contact

    Let’s talk about your project

    Leave your details and we’ll get back to you within 2 business hours for a free consultation. Tell us what you’re building - we’ll help you choose the right approach.

    Business hours

    Mon–Fri: 08:00 AM – 5:00 PM (PST)
    Sat–Sun: Closed

    Request a call

    Fill out the form and we’ll reach out shortly

    By clicking the button, you agree to our Privacy Policy

    We value your privacy

    We use analytics cookies, including third-party ones, to see how the site is used and improve it