Skip to main content
Skip to docs content

ARIA Labels Best Practices for Screen Readers

ARIA labels best practices start with understanding which attribute to use and how each screen reader announces it. The three primary labeling attributes (aria-label, aria-labelledby, and aria-describedby) serve different purposes and produce different screen reader announcements. Choosing the wrong one can result in labels being ignored, descriptions overriding names, or redundant speech that confuses users. This guide explains when to use each attribute, shows the predicted screen reader output via Speakable, and identifies common mistakes that create poor experiences across NVDA, JAWS, VoiceOver, and Narrator.

The Accessible Name Computation

Before understanding ARIA labels, you need to understand how browsers compute the "accessible name" for any element. The accessible name is the text that screen readers announce as the identity of an element. It is computed through a priority order defined in the W3C Accessible Name and Description Computation specification:

  1. aria-labelledby: Highest priority. References the IDs of other elements whose text content becomes the name.
  2. aria-label: Second priority. A string attribute directly on the element.
  3. Native labeling: The element's associated label (for inputs), alt text (for images), or text content (for buttons and links).
  4. Title attribute: Lowest priority fallback. Used only if no other name source exists.

This priority order means that aria-labelledby always wins, even over aria-label. If both are present, aria-labelledby takes precedence. Understanding this hierarchy prevents a common category of labeling bugs where developers add aria-label expecting it to supplement an aria-labelledby reference, but it gets ignored entirely.

The accessible description (computed from aria-describedby) is separate from the name. Screen readers announce the name first, then (often after a brief pause) the description. Some readers announce the description only in certain contexts or verbosity settings.

aria-label: When and How to Use It

The aria-label attribute provides an accessible name as a direct string value. It is invisible on screen but announced by screen readers as the element's identity.

Use aria-label when:

  • An element has no visible text content (icon-only buttons, for example).
  • The visible text is not descriptive enough for non-visual context.
  • You need to differentiate multiple instances of the same element (e.g., multiple "Close" buttons on a page).

Avoid aria-label when:

  • The element already has descriptive visible text (adding aria-label overrides it, creating a mismatch between what sighted and non-sighted users perceive).
  • You can use a visible label element instead (always prefer visible labels for form controls).
  • The element is not interactive and has no role that accepts a name (aria-label on a plain div is ignored by some screen readers).

Speakable Output Example

<!-- Icon button with aria-label -->
<button aria-label="Close dialog">
  <svg aria-hidden="true"><!-- X icon --></svg>
</button>

# npx @reticular/speakable close-button.html -f text -s all
#
# === NVDA ===
# Close dialog, button
#
# === JAWS ===
# Close dialog, button
#
# === VoiceOver ===
# Close dialog, button
#
# === Narrator ===
# Close dialog, button, to activate press Enter

Notice that all four readers announce the aria-label value as the button name. The SVG is hidden with aria-hidden="true" so it does not contribute text content. This is the correct pattern for icon-only buttons.

aria-labelledby: Composing Names from Visible Text

The aria-labelledby attribute references one or more element IDs whose text content is concatenated to form the accessible name. It is the highest priority in the name computation, overriding all other sources.

Use aria-labelledby when:

  • The label text already exists visually on the page (no need to duplicate it in aria-label).
  • You need to compose a name from multiple text sources (e.g., a heading plus a subtitle).
  • You want to associate a region or dialog with a visible heading.

Speakable Output Example

<!-- Dialog labeled by its heading -->
<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Deletion</h2>
  <p>Are you sure you want to delete this item?</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

# npx @reticular/speakable dialog.html -f text -s all
#
# === NVDA ===
# Confirm Deletion, dialog
# Confirm Deletion, heading level 2
# Are you sure you want to delete this item?
# Cancel, button
# Delete, button
#
# === VoiceOver ===
# Confirm Deletion, web dialog
# Confirm Deletion, heading level 2
# Are you sure you want to delete this item?
# Cancel, button
# Delete, button

The dialog's name comes from the text content of the element with id="dialog-title". This keeps the name synchronized with what is visually displayed. If you later change the heading text, the dialog name updates automatically because they reference the same source.

aria-describedby: Supplementary Information

The aria-describedby attribute provides an accessible description: additional context that supplements the accessible name. Unlike the name, the description is not the primary identity of the element. It provides extra information that helps users understand context or constraints.

Common uses for aria-describedby:

  • Linking form fields to their error messages or help text.
  • Providing format hints ("Date format: MM/DD/YYYY").
  • Connecting buttons to confirmation text ("This action cannot be undone").

How Descriptions Are Announced Differently

Screen readers treat descriptions differently from names. The name is always announced immediately. The description is typically announced after a brief pause, or only when the user requests more information. This behavior varies by reader:

<!-- Input with description -->
<label for="password">Password</label>
<input id="password" type="password" aria-describedby="pw-hint" />
<span id="pw-hint">Must be at least 8 characters with one uppercase letter</span>

# npx @reticular/speakable password-field.html -f text -s all
#
# === NVDA ===
# Password, edit, required
# Must be at least 8 characters with one uppercase letter
#
# === VoiceOver ===
# Password, required, secure text field
# Must be at least 8 characters with one uppercase letter
#
# === JAWS ===
# Password, edit, required
# Must be at least 8 characters with one uppercase letter
#
# === Narrator ===
# Password, edit, required
# Must be at least 8 characters with one uppercase letter

In practice, NVDA announces the description after the name and role with a brief pause. VoiceOver may require the user to press VO+Shift+N to hear descriptions in some contexts. JAWS announces descriptions after a configurable delay. The key point: descriptions are secondary to names. If critical information is in aria-describedby but not in the name, some users may miss it depending on their settings and reading speed.

aria-label vs aria-labelledby: Which to Choose

The choice between aria-label and aria-labelledby depends on whether suitable label text already exists in the DOM:

Criterionaria-labelaria-labelledby
Source of name textString value on the attributeText content of referenced element(s)
Visible to sighted usersNo (hidden label)Yes (references visible text)
Stays in sync with visible textNo (manual maintenance)Yes (automatic)
TranslatableRequires separate translation effortTranslated with the visible text
Priority in name computationSecond (overridden by aria-labelledby)Highest
Can compose from multiple sourcesNo (single string)Yes (space-separated IDs)
Best forIcon buttons, distinguishing duplicatesDialogs, sections, form groups

General rule: if the label text is already visible somewhere on the page, use aria-labelledby to point at it. If no visible text exists (icon buttons, for example), use aria-label. Prefer aria-labelledby when possible because it stays synchronized with the visible text and is automatically included in translation workflows.

Common Mistakes with ARIA Labels

These are the most frequent labeling mistakes found during screen reader testing:

Redundant Labels

Adding aria-label that duplicates the existing text content creates no benefit and sometimes causes double announcements in certain screen readers:

<!-- Bad: aria-label duplicates text content -->
<button aria-label="Submit">Submit</button>
<!-- Screen readers already use the text content "Submit" as the name -->

<!-- Good: just use the text content -->
<button>Submit</button>

# Both produce the same output:
# npx @reticular/speakable button.html -f text -s nvda
# Submit, button

Labels on Non-Interactive Elements

Adding aria-label to elements without a role that accepts a name (plain divs, spans, paragraphs) has no effect in most screen readers. The label is computed but never announced because the element has no interactive semantics:

<!-- Bad: aria-label on a plain div (ignored by most readers) -->
<div aria-label="Important notice">
  <p>Your account will expire soon.</p>
</div>

<!-- Good: use a role that accepts a name, or use visible text -->
<div role="alert" aria-label="Important notice">
  <p>Your account will expire soon.</p>
</div>

# The div version announces only the paragraph text.
# The role="alert" version announces: "Important notice, alert"

Overriding Good Native Semantics

A link with descriptive text content does not need aria-label. Adding one overrides the visible text and creates a mismatch between what sighted users see and what screen reader users hear. This breaks the "label in name" WCAG success criterion (2.5.3) and confuses voice control users who try to activate elements by their visible text.

<!-- Bad: aria-label overrides visible text -->
<a href="/pricing" aria-label="View our pricing plans and subscription options">
  Pricing
</a>
<!-- Voice control user says "click Pricing" but it doesn't match the accessible name -->

<!-- Good: visible text IS the accessible name -->
<a href="/pricing">Pricing</a>
<!-- Both sighted and non-sighted users see/hear "Pricing" -->

Testing ARIA Labels with Speakable

Speakable computes accessible names using the same algorithm browsers use, so you can verify that your ARIA labeling produces the intended announcements across all four screen readers:

# Check how a specific component's labels are announced
npx @reticular/speakable form.html -f text -s all

# Run audit mode to find labeling issues
npx @reticular/speakable form.html -f audit
# Reports: elements with no name, broken labelledby references, etc.

# Focus on a specific element with CSS selector
npx @reticular/speakable page.html -f text -s all --selector '[aria-labelledby]'

The audit mode specifically flags:

  • Interactive elements with no accessible name (buttons, links, inputs without labels)
  • Broken aria-labelledby references (pointing to IDs that do not exist in the document)
  • aria-label on elements whose role does not support naming
  • Mismatches between visible text and aria-label (potential "label in name" violations)

For ongoing verification, add Speakable to your CI pipeline. When someone accidentally removes a label or breaks an aria-labelledby reference, the audit will fail with a clear message about which element lost its name. This is particularly valuable for large teams where multiple developers modify the same components.

import { analyze } from "@reticular/speakable";

// Test that aria-labelledby correctly composes a name from multiple sources
const html = `
  <span id="action">Delete</span>
  <span id="target">user account</span>
  <button aria-labelledby="action target">
    <svg aria-hidden="true"><!-- trash icon --></svg>
  </button>
`;

const result = await analyze(html, {
  screenReaders: ["nvda", "voiceover"],
  format: "json",
});

// NVDA: "Delete user account, button"
// VoiceOver: "Delete user account, button"
// The name is composed from both referenced elements

Related Pages