| |

React 96 ⚛️ Accessibility (a11y) Roles and ARIA Attributes in React

⏱️ Estimated read: 14 min | |

Accessibility in React is not a separate layer applied after the component is built. It is a property of the component’s markup, and React’s rendering model gives you direct control over that markup. The WAI-ARIA specification defines roles, states, and properties that communicate the purpose and current condition of a UI element to assistive technology . In React, these attributes are passed as props with their exact ARIA names, and the framework warns when a prop does not exist in the specification. The challenge is not knowing that aria-label exists; it is knowing when to use a native HTML element instead, when a role is required, and how to keep generated IDs unique so that aria-labelledby and aria-describedby actually connect to the elements they reference.

Key point: The first rule of ARIA is that native HTML semantics are preferred. A <button> already has an implicit role and keyboard behavior. ARIA exists for custom widgets that cannot be expressed with native elements, and it only provides the semantics—the keyboard interaction and focus management must be implemented separately.


Why ARIA matters in React

The dynamic content problem. Static HTML communicates structure through its elements. A <nav> is a navigation landmark; a <button> is a control. When a component library renders a custom dropdown, a tab panel, or a modal, the underlying markup is often <div> and <span> elements that carry no semantic meaning. ARIA roles and attributes exist to restore that meaning for assistive technology . A <div role="tablist"> tells a screen reader that the element is a tab container, even though the element itself is generic.

The state communication problem. A visual interface communicates state through color, position, and animation. A checkbox that is checked looks different from one that is unchecked. A screen reader has no access to that visual difference. ARIA state attributes (aria-checked, aria-expanded, aria-selected) exist to communicate the same information through the accessibility tree . In React, these attributes are tied to component state, so when the state changes and the component re-renders, the ARIA attribute updates automatically.

The ID uniqueness problem. Attributes like aria-labelledby and aria-describedby reference other elements by ID. In a React application, a component may be rendered multiple times on the same page. Hardcoding an ID creates duplicates, and a duplicate ID breaks the reference. React’s useId hook generates unique IDs that are stable across server and client rendering, which is the correct solution for accessibility attributes that need to reference another element .

The invalid prop problem. React validates aria-* props against the WAI-ARIA specification. A typo or a non-existent attribute triggers a warning in development . This validation catches mistakes like aria-labelledby misspelled as aria-labelledBy or the common confusion of writing aria-role instead of role . The warning is a guardrail, but it does not catch semantic errors—a valid attribute used incorrectly will not warn.


a. Roles and the Preference for Native Elements

A role defines what an element is. It is set with the role attribute, not an aria- prefix. The role tells assistive technology how to interpret the element and what keyboard interactions to expect. In React, the role is passed as a standard prop.

// A custom tab list: the role communicates the structure
<div role="tablist" aria-label="Account settings">
  <button role="tab" aria-selected={activeTab === 'profile'} aria-controls="profile-panel">
    Profile
  </button>
  <button role="tab" aria-selected={activeTab === 'security'} aria-controls="security-panel">
    Security
  </button>
</div>

The role attribute is appropriate when the element cannot be expressed with a native HTML element that carries the same semantics. A <button> already has the button role. Adding role="button" to a <div> provides the semantic label but does not provide the keyboard activation (Enter and Space) or the focus behavior that a native <button> has. The ARIA Authoring Practices Guide recommends using native elements wherever possible and reserving ARIA roles for custom widgets that have no native equivalent .

For landmark roles, native elements are almost always better. A <nav> element provides the navigation landmark. A <main> element provides the main landmark. Using <div role="navigation"> works, but it adds an attribute where an element would suffice.


b. States, Properties, and React State

ARIA states and properties describe the current condition of an element. They are passed as props with the exact aria- prefixed names from the specification. The values are typically derived from React state, which means the accessibility tree updates whenever the component re-renders.

function Accordion({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const panelId = useId();
  const buttonId = useId();

  return (
    <div>
      <button
        id={buttonId}
        aria-expanded={isOpen}
        aria-controls={panelId}
        onClick={() => setIsOpen(!isOpen)}
      >
        {title}
      </button>
      <div id={panelId} role="region" aria-labelledby={buttonId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}

The aria-expanded attribute reflects the isOpen state. When the button is clicked and the state changes, React re-renders the button with the new aria-expanded value. The aria-controls attribute references the panel’s ID, and the aria-labelledby on the panel references the button’s ID. Both IDs are generated with useId, which ensures they are unique even if the component appears multiple times on the page .

The aria-label and aria-labelledby attributes provide the accessible name for an element. aria-label takes a string; aria-labelledby takes the ID of another element whose text content becomes the name. When an element has a visible label, aria-labelledby is preferred because it keeps the accessible name synchronized with the visible text. When there is no visible label—an icon button, for example—aria-label is required .

// Icon button with no visible text: aria-label provides the name
<button aria-label="Close dialog" onClick={onClose}>
  <CloseIcon aria-hidden="true" />
</button>

The aria-hidden="true" on the icon prevents the screen reader from announcing the icon’s content, which would otherwise be meaningless. The button’s aria-label is the only name the user hears.


c. Live Regions and Dynamic Announcements

Live regions announce content changes to screen readers. They are used when a part of the page updates without a full navigation—a form validation error appearing, a toast notification, or a search result count changing. The aria-live attribute defines the politeness level: polite waits for the user to finish their current action, and assertive interrupts.

function SearchResults({ results, isLoading }) {
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    if (!isLoading) {
      setAnnouncement(`${results.length} results found`);
    }
  }, [results, isLoading]);

  return (
    <div>
      <div aria-live="polite" className="visually-hidden">
        {announcement}
      </div>
      {/* visible results */}
    </div>
  );
}

The live region element must be present in the DOM before the content changes. If the element is inserted into the DOM at the same time as the announcement, the screen reader may not announce it. The pattern above keeps the live region mounted and updates its text content, which triggers the announcement.

The role="alert" attribute is a shorthand for an assertive live region. It is appropriate for error messages that require immediate attention. For less urgent updates, aria-live="polite" is preferred because it does not interrupt the user’s current task.


Complete Example Session

// ============================================
// PART 1: NATIVE ELEMENT PREFERENCE
// ============================================
// A button with a visible label needs no ARIA
<button onClick={onSave}>Save changes</button>
// The <button> element provides the role,
// keyboard activation, and focus behavior.


// ============================================
// PART 2: ICON BUTTON WITH ARIA-LABEL
// ============================================
// No visible text: aria-label provides the name
<button aria-label="Delete item" onClick={onDelete}>
  <TrashIcon aria-hidden="true" />
</button>


// ============================================
// PART 3: UNIQUE IDS WITH USEID
// ============================================
import { useId } from 'react';

function EmailField({ label, hint }) {
  const inputId = useId();
  const hintId = useId();

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        type="email"
        aria-describedby={hintId}
      />
      <p id={hintId}>{hint}</p>
    </div>
  );
}


// ============================================
// PART 4: CUSTOM TAB LIST WITH ROLES AND STATES
// ============================================
function Tabs({ tabs, activeIndex, onChange }) {
  const baseId = useId();

  return (
    <div role="tablist" aria-label="Settings sections">
      {tabs.map((tab, index) => (
        <button
          key={tab.id}
          role="tab"
          id={`${baseId}-tab-${index}`}
          aria-selected={index === activeIndex}
          aria-controls={`${baseId}-panel-${index}`}
          onClick={() => onChange(index)}
        >
          {tab.label}
        </button>
      ))}
    </div>
  );
}


// ============================================
// PART 5: EXPANDABLE SECTION WITH ARIA-EXPANDED
// ============================================
function Disclosure({ title, children }) {
  const [isOpen, setIsOpen] = useState(false);
  const panelId = useId();

  return (
    <div>
      <button
        aria-expanded={isOpen}
        aria-controls={panelId}
        onClick={() => setIsOpen(!isOpen)}
      >
        {title}
      </button>
      <div id={panelId} hidden={!isOpen}>
        {children}
      </div>
    </div>
  );
}


// ============================================
// PART 6: MODAL WITH LABELLEDBY AND DESCRIBEDBY
// ============================================
function Modal({ title, description, onClose }) {
  const titleId = useId();
  const descriptionId = useId();

  return (
    <div role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={descriptionId}>
      <h2 id={titleId}>{title}</h2>
      <p id={descriptionId}>{description}</p>
      <button onClick={onClose}>Close</button>
    </div>
  );
}


// ============================================
// PART 7: LIVE REGION FOR FORM VALIDATION
// ============================================
function FormField({ label, error }) {
  const inputId = useId();
  const errorId = useId();

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      <div id={errorId} role="alert" aria-live="assertive">
        {error}
      </div>
    </div>
  );
}


// ============================================
// PART 8: INVALID ARIA PROP WARNING
// ============================================
// <div aria-role="button"> triggers a warning.
// Use role="button" instead.
// <div aria-labelledBy="..."> triggers a warning.
// Use aria-labelledby="..." instead.
// React validates aria-* props against the WAI-ARIA spec.


// ============================================
// PART 9: HIDING DECORATIVE CONTENT
// ============================================
// Icons, images, and decorative elements
// should not be announced by screen readers
<span aria-hidden="true">★</span>


// ============================================
// PART 10: TYPED ARIA PROPS IN TYPESCRIPT
// ============================================
// TypeScript enforces the ARIA contract at compile time
interface SwitchProps {
  role: 'switch';
  'aria-checked': boolean;
  'aria-label': string;
  onClick: () => void;
}

function Switch({ role, 'aria-checked': checked, 'aria-label': label, onClick }: SwitchProps) {
  return (
    <button role={role} aria-checked={checked} aria-label={label} onClick={onClick} />
  );
}

The ten parts move from the preference for native elements through ID generation, custom widget roles, live regions, and TypeScript enforcement. Part 3 and Part 6 show the correct use of useId for attributes that reference other elements. Part 8 shows the warning that catches invalid or misspelled aria-* props.


Quick Reference

Core ARIA Attributes in React

AttributePurposeValue Type
roleDefines element typeString (not aria-role)
aria-labelAccessible name when no visible labelString
aria-labelledbyID of element that provides the nameString (ID)
aria-describedbyID of element that provides a descriptionString (ID)
aria-expandedWhether a disclosure is openBoolean
aria-selectedWhether a tab or option is selectedBoolean
aria-checkedWhether a checkbox or switch is checkedBoolean
aria-livePoliteness of live regionpolite / assertive
aria-hiddenWhether element is hidden from ATBoolean

Role vs. Native Element

Custom RoleNative Equivalent
role="button"<button>
role="navigation"<nav>
role="main"<main>
role="dialog"<dialog> (with polyfill)
role="checkbox"<input type="checkbox">

ID Generation Rules

NeedSolution
Unique ID for aria-describedbyuseId()
Unique ID for aria-labelledbyuseId()
Multiple related IDsOne useId as prefix
Multiple React roots on a pageidentifierPrefix on createRoot

Best Practices

✅ Do This:

// Prefer native elements over ARIA roles
<button onClick={onSave}>Save</button>                                  // ✅
// Use useId for IDs referenced by aria-* attributes
const hintId = useId();
<input aria-describedby={hintId} />                                     // ✅
// Derive ARIA state from React state
<button aria-expanded={isOpen} />                                       // ✅
// Use aria-label for icon-only buttons
<button aria-label="Close"><CloseIcon aria-hidden /></button>            // ✅
// Use role="alert" for urgent error messages
<div role="alert">{error}</div>                                        // ✅

❌ Don’t Do This:

// Use role="button" on a div without keyboard handlers
<div role="button" onClick={onClick}>Click</div>                       // ❌ (no Enter/Space)
// Hardcode IDs for aria-labelledby
<div aria-labelledby="modal-title">                                    // ❌ (duplicates on repeat render)
// Write aria-role instead of role
<div aria-role="button">                                               // ❌ (invalid prop warning)
// Use aria-hidden on focusable elements
<button aria-hidden="true">Close</button>                              // ❌ (unreachable)

Common Pitfalls

PitfallWhy It HappensFix
Duplicate IDs break referencesHardcoded id in reusable componentUse useId
Screen reader does not announce stateARIA attribute not tied to React stateDerive value from useState
Custom widget not keyboard accessibleRole added without key handlersAdd Enter/Space handling
Invalid ARIA prop warningTypo or non-spec attributeCheck spelling against WAI-ARIA spec
Live region not announcedElement inserted at same time as contentKeep live region mounted

Real-World Examples

1. Icon Button with Accessible Name

<button aria-label="Search"><SearchIcon aria-hidden /></button>

2. Input with Description

const hintId = useId();
<input aria-describedby={hintId} />
<p id={hintId}>Must be 8+ characters</p>

3. Expandable Section

<button aria-expanded={isOpen} aria-controls={panelId}>Toggle</button>

4. Modal Dialog

<div role="dialog" aria-modal="true" aria-labelledby={titleId}>

5. Tab List

<div role="tablist">
  <button role="tab" aria-selected={active} aria-controls={panelId}>Tab</button>
</div>

6. Live Region for Search Count

<div aria-live="polite">{count} results</div>

7. Decorative Icon Hidden

<span aria-hidden="true">★</span>

8. Invalid Form Field

<input aria-invalid={!!error} aria-describedby={errorId} />

9. Switch Component

<button role="switch" aria-checked={enabled}>Notifications</button>

10. TypeScript ARIA Constraint

interface TabProps { role: 'tab'; 'aria-selected': boolean; }

Visual

Native Element vs. ARIA Role

┌──────────────────────────────────────────────────────────────┐
│  NATIVE <button>              <div role="button">            │
│                                                              │
│  ┌─────────────────┐          ┌─────────────────┐           │
│  │ Role: button    │          │ Role: button    │           │
│  │ Keyboard:       │          │ Keyboard:       │           │
│  │ Enter/Space     │          │ NONE            │           │
│  │ Focusable: Yes  │          │ Focusable: No   │           │
│  │ (by default)    │          │ (needs tabIndex)│           │
│  └─────────────────┘          └─────────────────┘           │
│                                                              │
│  ARIA provides semantics only.                               │
│  Behavior must be implemented separately.                    │
│  Prefer the native element.                                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

useId and ARIA References

┌──────────────────────────────────────────────────────────────┐
│  COMPONENT RENDERED TWICE                                    │
│                                                              │
│  First instance:                                             │
│  ┌─────────────────┐                                        │
│  │ input           │                                        │
│  │ aria-describedby│──────► id=":r1:"                       │
│  │ id=":r0:"       │                                        │
│  └─────────────────┘                                        │
│  ┌─────────────────┐                                        │
│  │ <p id=":r1:">   │                                        │
│  └─────────────────┘                                        │
│                                                              │
│  Second instance:                                            │
│  ┌─────────────────┐                                        │
│  │ input           │                                        │
│  │ aria-describedby│──────► id=":r3:"                       │
│  │ id=":r2:"       │                                        │
│  └─────────────────┘                                        │
│  ┌─────────────────┐                                        │
│  │ <p id=":r3:">   │                                        │
│  └─────────────────┘                                        │
│                                                              │
│  useId generates unique IDs per instance.                    │
│  References remain correct on every render.                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

State Attribute Updates

┌──────────────────────────────────────────────────────────────┐
│  REACT STATE                    DOM ATTRIBUTE                │
│                                                              │
│  isOpen = false      ────►      aria-expanded="false"        │
│                                                              │
│  User clicks button                                          │
│         │                                                    │
│         ▼                                                    │
│  setIsOpen(true)                                             │
│         │                                                    │
│         ▼                                                    │
│  Re-render                                                   │
│         │                                                    │
│         ▼                                                    │
│  isOpen = true       ────►      aria-expanded="true"         │
│                                                              │
│  The ARIA attribute is derived from state.                   │
│  State change → re-render → attribute update → AT notified.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Invalid ARIA Prop Warning

┌──────────────────────────────────────────────────────────────┐
│  <div aria-labelledBy="title">                               │
│                                                              │
│  React checks the prop name against WAI-ARIA spec            │
│         │                                                    │
│         ▼                                                    │
│  "aria-labelledBy" not found                                 │
│  (correct: "aria-labelledby", lowercase "by")                │
│         │                                                    │
│         ▼                                                    │
│  Development warning:                                        │
│  "Invalid ARIA prop"                                         │
│                                                              │
│  Check spelling carefully.                                   │
│  aria-labelledby and aria-activedescendant are often         │
│  misspelled.                                                 │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Role attributerole (not aria-role)
Accessible namearia-label or aria-labelledby
Descriptionaria-describedby
State attributesaria-expanded, aria-selected, aria-checked
Live regionaria-live="polite" or role="alert"
Hide decorative contentaria-hidden="true"
Unique ID generationuseId()
Invalid prop warningReact validates aria-* against spec
Native element preferenceUse <button>, <nav> over ARIA roles

Key takeaways:

  • Native HTML semantics come first. A <button> provides role, keyboard activation, and focus behavior without ARIA. A <div role="button"> provides the role but none of the behavior. Use ARIA roles only for custom widgets that have no native equivalent .
  • ARIA state attributes must be derived from React state. aria-expanded, aria-selected, and aria-checked should be set from the same state that drives the visual rendering. When the state changes, React re-renders and the attribute updates, keeping the accessibility tree synchronized with the UI.
  • useId solves the ID uniqueness problem. Hardcoded IDs break when a component is rendered multiple times. useId generates unique IDs that are stable across server and client rendering, which is what aria-labelledby and aria-describedby require .
  • React warns on invalid aria-* props. A typo or non-existent attribute triggers a development warning. The warning catches spelling mistakes like aria-labelledby and the common confusion of aria-role versus role .
  • Live regions announce dynamic changes. The aria-live attribute and role="alert" tell screen readers to announce content that changes without a page navigation. The live region element must be mounted before the content changes.

Remember: Accessibility in React is a property of the rendered markup. The component’s state determines the ARIA attributes, the element type determines the implicit role, and the IDs connect the accessible name and description to the elements that provide them. The framework does not enforce semantic correctness—it validates prop names, not whether aria-label is the right choice for a given element. The decisions that matter are the ones the warning cannot catch: choosing a native element over an ARIA role, deriving state attributes from state rather than hardcoding them, and ensuring that every custom widget has the keyboard behavior its role implies.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!