Dev Encyclopedia
ArticlesTools

Get notified when new content drops

No spam. Just new articles, tools, and updates — straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

© 2026 Dev Encyclopedia

Privacy PolicyTermsDisclaimer
  1. Home
  2. /Blog
  3. /ARIA in React: Stop Using aria-label Wrong
react10 min read

ARIA in React: Stop Using aria-label Wrong

Pages using ARIA average 41% more accessibility errors. Learn the correct ARIA patterns for React: icon buttons, modals, toasts, spinners, and tab panels.

By Dev EncyclopediaPublished June 1, 2026
On this page

On this page

  • The Rules You Need First
  • The 5 Patterns
  • Pattern 1: Icon Button
  • Pattern 2: Modal Dialog
  • Pattern 3: Toast Notifications
  • Pattern 4: Loading Spinner
  • Pattern 5: Tab Panel
  • Accessibility Shipping Checklist
  • Testing Tools
  • Frequently Asked Questions

WebAIM's annual accessibility analysis found that pages using ARIA attributes average 41% more accessibility errors than pages without ARIA at all. That's not a reason to avoid ARIA — it's a sign that most developers reach for it reflexively and apply it wrong.

This post covers the five React component patterns where developers consistently get ARIA wrong, and what correct looks like in each case. Each pattern shows the wrong implementation alongside the correct one.

The Rules You Need to Know First

ARIA exists to bridge gaps when native HTML can't convey enough meaning to assistive technologies. The W3C's first rule of ARIA: if a native HTML element can do the job, use it. A <button> is already accessible — adding role="button" to a <div> is extra work that creates new failure modes.

⚠ Warning

Misused ARIA doesn't just fail to help — it actively breaks the experience for screen reader users by overriding correct semantics with incorrect ones.

When you do need ARIA, the most important choice is which labelling attribute to use:

aria-labelaria-labelledby
Value typeString you write directlyID reference to another element
When to useNo visible text exists that names the elementVisible text already names the element
Translation-safe?No — skipped by browser translatorsYes — translated visible text = translated accessible name
Canonical use caseIcon-only button with no labelModal dialog pointing to its visible h2 title

💡 Tip

Use aria-labelledby over aria-label whenever visible text already names the element. A German-speaking user who translates your English page hears English aria-label strings while reading German text — a broken experience aria-labelledby avoids entirely.

The 5 Patterns

  1. 1

    Icon Button with No Visible Text

    This is one of the few legitimate uses for aria-label — when there is genuinely no visible text and adding a label isn't feasible. Two details matter: the aria-label on the button and aria-hidden="true" on the icon to prevent the screen reader from also describing the SVG.

    tsx
    // ❌ Wrong — no accessible name, screen reader announces nothing useful
    <button onClick={onClose}>
      <XIcon />
    </button>
    
    // ✅ Correct — aria-label gives it an accessible name
    <button onClick={onClose} aria-label="Close dialog">
      <XIcon aria-hidden="true" />
    </button>
    • aria-hidden="true" on the icon prevents the screen reader from announcing "Close dialog, svg" — redundant once the button has a name
    • Without aria-hidden on the icon, screen readers may announce both the aria-label and the SVG title
    • If a visible tooltip exists, consider making it the accessible name via aria-labelledby instead

    💡 Tip

    Test with VoiceOver (Cmd+F5 on Mac): navigate to the button and confirm it announces "Close dialog, button" — not "svg" or "button".

  2. 2

    Modal Dialog

    A modal needs three things: role="dialog", a label connecting it to its visible title, and focus management. Most React implementations get one or two and miss the third.

    tsx — ❌ Wrong — missing label and focus management
    <div className="modal">
      <h2>Confirm Delete</h2>
      <p>Are you sure?</p>
      <button onClick={onClose}>Cancel</button>
    </div>
    tsx — ✅ Correct — role, aria-labelledby, focus management
    function Modal({ isOpen, title, children, onClose }) {
      const titleId = useId();
      const modalRef = useRef<HTMLDivElement>(null);
    
      useEffect(() => {
        if (isOpen) modalRef.current?.focus();
      }, [isOpen]);
    
      if (!isOpen) return null;
    
      return (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}  // points to visible title — translation-safe
          tabIndex={-1}
          ref={modalRef}
        >
          <h2 id={titleId}>{title}</h2>
          {children}
          <button onClick={onClose}>Close</button>
        </div>
      );
    }
    • aria-modal="true" tells screen readers to treat content outside the dialog as inert — without it, some screen readers let users navigate behind the modal
    • tabIndex={-1} allows the div to receive programmatic focus from useEffect without entering the tab order
    • Focus must return to the triggering element when the modal closes — add triggerRef.current?.focus() in the close handler

    ℹ Info

    For production, use Radix UI or Headless UI — focus management and ARIA wiring are handled correctly. The edge cases in modal accessibility are numerous.

  3. 3

    Toast Notifications with aria-live

    Toast notifications are the most common case where developers forget ARIA entirely, leaving screen reader users unaware a message appeared.

    tsx — ❌ Wrong — dynamically injecting the live region
    // Injecting a live region at toast time — screen readers won't announce it
    {showToast && (
      <div aria-live="polite">{message}</div>
    )}
    tsx — ✅ Correct — persistent live region, always in the DOM
    function ToastContainer({ message }: { message: string | null }) {
      return (
        // Always rendered — screen readers observe it from page load
        // aria-atomic="true" announces the whole region, not just changed text
        <div
          aria-live="polite"
          aria-atomic="true"
          className="sr-only"
        >
          {message}
        </div>
      );
    }
    • The container must always be in the DOM — screen readers only observe live regions that existed when the page loaded
    • aria-live="polite" waits for current speech to finish — use for success messages and status updates
    • aria-live="assertive" interrupts immediately — reserve for genuine errors requiring immediate attention

    🚫 Danger

    Never dynamically inject the live region at toast time. Render the container on mount with empty content, then update it.

  4. 4

    Loading Spinner

    A spinning animation with no text tells a screen reader nothing. role="status" is live region shorthand for aria-live="polite".

    tsx — ❌ Wrong — no accessible information
    <div className="spinner">
      <svg className="animate-spin" />
    </div>
    tsx — ✅ Correct — role, label, hidden icon
    function LoadingSpinner({ label = "Loading..." }: { label?: string }) {
      return (
        <div role="status" aria-label={label}>
          <svg aria-hidden="true" className="animate-spin" />
          <span className="sr-only">{label}</span>
        </div>
      );
    }
    • aria-hidden="true" prevents screen readers from attempting to describe the SVG animation
    • The sr-only span provides a fallback in browsers where live region aria-label support is inconsistent
    • When loading completes, ensure focus or a content update announces the result — the spinner disappearing is not announced

    💡 Tip

    Test with VoiceOver: when the spinner mounts, you should hear "Loading..." announced. When it unmounts, nothing should be announced.

  5. 5

    Tab Panel

    Custom tab components are common in React and almost universally implemented without correct ARIA. The aria-controls / aria-labelledby pairing creates a semantic relationship between each tab and its panel.

    tsx — ❌ Wrong — no ARIA roles or relationships
    <div className="tabs">
      {tabs.map((tab, i) => (
        <button
          key={tab.id}
          className={activeTab === i ? 'active' : ''}
          onClick={() => setActiveTab(i)}
        >
          {tab.label}
        </button>
      ))}
      <div>{tabs[activeTab].content}</div>
    </div>
    tsx — ✅ Correct — full ARIA tab pattern
    function Tabs({ tabs }: { tabs: Tab[] }) {
      const [activeTab, setActiveTab] = useState(0);
    
      return (
        <div>
          <div role="tablist" aria-label="Account settings">
            {tabs.map((tab, i) => (
              <button
                key={tab.id}
                role="tab"
                aria-selected={activeTab === i}   // active state — not aria-pressed
                aria-controls={`panel-${tab.id}`}  // links tab to its panel
                id={`tab-${tab.id}`}
                onClick={() => setActiveTab(i)}
              >
                {tab.label}
              </button>
            ))}
          </div>
    
          {tabs.map((tab, i) => (
            <div
              key={tab.id}
              role="tabpanel"
              id={`panel-${tab.id}`}
              aria-labelledby={`tab-${tab.id}`}
              hidden={activeTab !== i}
            >
              {tab.content}
            </div>
          ))}
        </div>
      );
    }
    • aria-selected communicates the active tab — aria-pressed is wrong here and confuses the tab pattern
    • hidden is preferred over CSS display: none — it removes inactive panels from the accessibility tree entirely
    • Arrow-key navigation between tabs is part of the ARIA pattern — a tab that only responds to click fails keyboard users

    💡 Tip

    Radix UI's <Tabs> implements the complete keyboard navigation pattern (Left/Right arrows between tabs). Use it for production rather than rolling your own.

Accessibility Shipping Checklist

  • Icon-only buttons have aria-label and SVG has aria-hidden="true"
  • Modals have role="dialog", aria-modal="true", aria-labelledby pointing to the visible title, and focus management
  • Toast/notification container is always in the DOM with aria-live="polite" and aria-atomic="true"
  • Loading spinners have role="status" and aria-hidden on the SVG
  • Tab components use role="tab", aria-selected, aria-controls, and role="tabpanel" with aria-labelledby
  • Ran axe DevTools — zero violations
  • Tested keyboard navigation — every interactive element reachable without a mouse
  • Tested with VoiceOver or NVDA — announcements make sense without visual context

Testing Tools

Automated tools catch 30–40% of issues. Manual screen reader testing catches the rest.

ToolWhat it catchesWhen to use
axe DevTools (browser extension)Missing labels, ARIA misuse, contrastDuring development, component-level
VoiceOver (Mac, Cmd+F5)Announcement quality, focus flow, live regionsBefore shipping any interactive component
NVDA (Windows, free)Same as VoiceOver — different engineCross-browser/platform verification
@axe-core/reactViolations logged to console automaticallyDevelopment builds, CI accessibility checks

ℹ Info

The axe extension and Lighthouse accessibility audit share the same axe-core engine. Running both is redundant. Use axe for component-level testing and Lighthouse for full-page audits before deployment.

Add @axe-core/react to your development build in 5 lines:

typescript — src/main.tsx or src/index.tsx
if (process.env.NODE_ENV !== 'production') {
  const axe = await import('@axe-core/react');
  const React = await import('react');
  const ReactDOM = await import('react-dom');
  axe.default(React.default, ReactDOM.default, 1000);
}

Frequently Asked Questions

Is ARIA required to make a React app accessible?
  • When ARIA is required: role="dialog" for modals, aria-live for dynamic announcements, role="tab" / role="tabpanel" for custom tab patterns — interactive patterns with no native HTML equivalent.
  • When ARIA is not required: styling hooks, layout wrappers, server-rendered static content, anything a native element already handles correctly.
  • Most accessibility comes from correct semantic HTML. A <button> is accessible without ARIA. A <label> with htmlFor already associates with its input.
What is aria-hidden and when should I use it?

aria-hidden="true" removes an element from the accessibility tree without hiding it visually. Screen readers skip it entirely.

Use aria-hiddenDo NOT use aria-hidden
Decorative icons/SVGsYes — adds no meaning beyond visualNo
Focusable elementsNoYes — creates silent keyboard dead-ends
Duplicate contentYes — if conveyed another wayNo
Does aria-label get translated by browser translation tools?

No. Chrome, Edge, and Firefox translation extensions translate visible DOM text but skip aria-label strings. A user who translates your English page hears English aria-label while reading translated visible content.

Fix: use aria-labelledby to reference visible text. When the visible text is translated, the accessible name is translated automatically.

What is the difference between aria-live="polite" and aria-live="assertive"?
politeassertive
TimingAnnounces after current speech finishesInterrupts immediately
Use forSuccess toasts, status messages, form feedbackCritical errors requiring immediate action
Default choice?Yes — when in doubt, use politeNo — use sparingly
How much of my accessibility do automated tools actually check?

Automated tools (axe, Lighthouse) catch roughly 30–40% of accessibility issues — primarily missing labels, contrast failures, and clear structural violations.

They cannot catch: whether announced content makes sense in context, whether focus management feels correct, whether live region timing is appropriate, or whether keyboard navigation follows expected patterns. Manual testing with VoiceOver or NVDA is the only way to catch the remaining 60–70%.

The 41% stat isn't an argument against ARIA — it's an argument against reflexive ARIA. Reach for native HTML first. When you do need ARIA, use aria-labelledby over aria-label whenever visible text already names the element.

Test with a screen reader before shipping. Axe catches structure violations; only VoiceOver or NVDA tells you whether the experience actually makes sense when you can't see the screen.

Related Articles

nextjs

How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)

Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.

May 30, 2026·7 min read
html css

8 CSS :has() Patterns You'll Actually Use (2026)

CSS :has() is production-ready in every browser. Here are 8 real-world patterns — form states, sibling dimming, modal scroll-lock, and more.

May 30, 2026·9 min read
nextjs

Husky + Prettier + lint-staged Setup for Next.js

Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.

May 30, 2026·8 min read

On this page

  • The Rules You Need First
  • The 5 Patterns
  • Pattern 1: Icon Button
  • Pattern 2: Modal Dialog
  • Pattern 3: Toast Notifications
  • Pattern 4: Loading Spinner
  • Pattern 5: Tab Panel
  • Accessibility Shipping Checklist
  • Testing Tools
  • Frequently Asked Questions