React 96 ⚛️ Accessibility (a11y) Roles and ARIA Attributes in React
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
| Attribute | Purpose | Value Type |
|---|---|---|
role | Defines element type | String (not aria-role) |
aria-label | Accessible name when no visible label | String |
aria-labelledby | ID of element that provides the name | String (ID) |
aria-describedby | ID of element that provides a description | String (ID) |
aria-expanded | Whether a disclosure is open | Boolean |
aria-selected | Whether a tab or option is selected | Boolean |
aria-checked | Whether a checkbox or switch is checked | Boolean |
aria-live | Politeness of live region | polite / assertive |
aria-hidden | Whether element is hidden from AT | Boolean |
Role vs. Native Element
| Custom Role | Native Equivalent |
|---|---|
role="button" | <button> |
role="navigation" | <nav> |
role="main" | <main> |
role="dialog" | <dialog> (with polyfill) |
role="checkbox" | <input type="checkbox"> |
ID Generation Rules
| Need | Solution |
|---|---|
Unique ID for aria-describedby | useId() |
Unique ID for aria-labelledby | useId() |
| Multiple related IDs | One useId as prefix |
| Multiple React roots on a page | identifierPrefix 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Duplicate IDs break references | Hardcoded id in reusable component | Use useId |
| Screen reader does not announce state | ARIA attribute not tied to React state | Derive value from useState |
| Custom widget not keyboard accessible | Role added without key handlers | Add Enter/Space handling |
| Invalid ARIA prop warning | Typo or non-spec attribute | Check spelling against WAI-ARIA spec |
| Live region not announced | Element inserted at same time as content | Keep 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
| Item | Value |
|---|---|
| Role attribute | role (not aria-role) |
| Accessible name | aria-label or aria-labelledby |
| Description | aria-describedby |
| State attributes | aria-expanded, aria-selected, aria-checked |
| Live region | aria-live="polite" or role="alert" |
| Hide decorative content | aria-hidden="true" |
| Unique ID generation | useId() |
| Invalid prop warning | React validates aria-* against spec |
| Native element preference | Use <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, andaria-checkedshould 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. useIdsolves the ID uniqueness problem. Hardcoded IDs break when a component is rendered multiple times.useIdgenerates unique IDs that are stable across server and client rendering, which is whataria-labelledbyandaria-describedbyrequire .- React warns on invalid
aria-*props. A typo or non-existent attribute triggers a development warning. The warning catches spelling mistakes likearia-labelledbyand the common confusion ofaria-roleversusrole. - Live regions announce dynamic changes. The
aria-liveattribute androle="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.
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!