View contents
Introduction
ARIA labels provide accessible names for elements that screen readers announce to users. When native HTML doesn’t provide a clear name—like icon-only buttons or custom widgets—ARIA attributes fill the gap. WCAG 4.1.2 requires that all user interface components have programmatically determinable names, making ARIA labeling essential for accessibility.
Without proper accessible names, screen reader users hear meaningless announcements like “button” or “link” without understanding what the element does. ARIA labels ensure every interactive element communicates its purpose clearly.
Understanding Accessible Names
What Is an Accessible Name?
An accessible name is the text that assistive technologies announce when a user focuses on an element. It can come from various sources:
| Source | Priority | Example |
|---|---|---|
aria-labelledby |
Highest | References another element’s text |
aria-label |
High | Direct label text |
<label> element |
Medium | Form field labels |
| Element content | Lower | Text inside buttons/links |
title attribute |
Lowest | Last resort option |
The Name Calculation
Browsers use a specific algorithm to determine accessible names:
<!-- 1. aria-labelledby wins (highest priority) -->
<button aria-labelledby="btn-label">
<svg>...</svg>
</button>
<span id="btn-label">Search products</span>
<!-- Announced: "Search products, button" -->
<!-- 2. aria-label is next -->
<button aria-label="Search products">
<svg>...</svg>
</button>
<!-- Announced: "Search products, button" -->
<!-- 3. Content is lower priority -->
<button>
<svg aria-hidden="true">...</svg>
Search
</button>
<!-- Announced: "Search, button" -->
ARIA Labeling Attributes
aria-label
Provides a string directly as the accessible name:
<!-- Icon-only button -->
<button aria-label="Close dialog">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<!-- Search input -->
<input type="search" aria-label="Search products">
Best for:
- Icon-only buttons
- Inputs without visible labels
- Elements where visual context is clear
Limitations:
- Not translatable by browser translation tools
- Not visible to sighted users
aria-labelledby
References another element’s text as the label:
<!-- Using existing heading -->
<h2 id="section-title">Shopping Cart</h2>
<nav aria-labelledby="section-title">
<!-- Navigation within shopping cart -->
</nav>
<!-- Multiple elements -->
<span id="label">Email</span>
<span id="hint">(required)</span>
<input type="email" aria-labelledby="label hint">
<!-- Announced: "Email (required)" -->
Best for:
- When label text already exists visually
- Combining multiple text sources
- Sections labeled by headings
aria-describedby
Provides supplementary description (not the primary name):
<label for="password">Password</label>
<input type="password" id="password" aria-describedby="pwd-help">
<p id="pwd-help">Must be at least 8 characters with one number</p>
<!-- Announced: "Password, edit text, Must be at least 8 characters..." -->
Difference from labelledby:
aria-labelledby= primary name (announced first)aria-describedby= additional description (announced after)
Common ARIA Label Use Cases
Icon Buttons
<!-- Social media icons -->
<a href="https://twitter.com/company" aria-label="Follow us on Twitter">
<svg aria-hidden="true"><!-- Twitter icon --></svg>
</a>
<button aria-label="Add to favorites">
<svg aria-hidden="true"><!-- Heart icon --></svg>
</button>
Navigation Landmarks
<!-- Multiple nav elements need distinction -->
<nav aria-label="Main navigation">
<!-- Primary navigation -->
</nav>
<nav aria-label="Footer navigation">
<!-- Footer links -->
</nav>
<nav aria-label="Breadcrumb">
<!-- Breadcrumb trail -->
</nav>
Form Regions
<section aria-labelledby="shipping-title">
<h2 id="shipping-title">Shipping Information</h2>
<!-- Shipping form fields -->
</section>
<section aria-labelledby="billing-title">
<h2 id="billing-title">Billing Information</h2>
<!-- Billing form fields -->
</section>
Custom Widgets
<!-- Star rating -->
<div role="slider"
aria-label="Rating"
aria-valuemin="1"
aria-valuemax="5"
aria-valuenow="4">
<!-- Star icons -->
</div>
<!-- Toggle switch -->
<button role="switch"
aria-checked="false"
aria-label="Dark mode">
<!-- Toggle visual -->
</button>
Common Labeling Mistakes
1. Missing Labels
<!-- BAD: No accessible name -->
<button>
<svg><!-- icon --></svg>
</button>
<!-- Announced: "button" - meaningless! -->
<!-- GOOD: Has accessible name -->
<button aria-label="Delete item">
<svg aria-hidden="true"><!-- icon --></svg>
</button>
2. Redundant Labels
<!-- BAD: Redundant information -->
<button aria-label="Submit button">Submit</button>
<!-- Announced: "Submit button, button" - "button" is said twice -->
<!-- GOOD: Let content be the name -->
<button>Submit</button>
<!-- Announced: "Submit, button" -->
3. Labels Not Matching Visible Text
<!-- BAD: Different from visible text -->
<button aria-label="Send message">Submit Form</button>
<!-- Voice control users say "click Submit Form" but it won't work -->
<!-- GOOD: Match or include visible text -->
<button aria-label="Submit Form - sends your message">Submit Form</button>
4. Using aria-label on Non-Interactive Elements
<!-- BAD: aria-label on div (screen readers may ignore) -->
<div aria-label="Important section">Content here</div>
<!-- GOOD: Use on landmarks or interactive elements -->
<section aria-label="Important section">Content here</section>
Testing ARIA Labels
Quick Manual Test
- Open browser DevTools
- Inspect an element
- Check the Accessibility panel
- Look for “Name” property
- Verify it makes sense
Using Screen Readers
| Screen Reader | Command |
|---|---|
| NVDA | Tab to element, listen |
| VoiceOver | VO+Right arrow |
| JAWS | Tab to element |
| Narrator | Tab to element |
Automated Tools
- axe DevTools - Reports missing accessible names
- WAVE - Highlights unlabeled elements
- Lighthouse - Checks for accessible names
- Browser DevTools - Shows computed accessible name
Best Practices Summary
| Do | Don’t |
|---|---|
Use aria-label for icon-only elements |
Put aria-label on non-interactive divs |
Use aria-labelledby when text exists |
Duplicate visible text in aria-label |
| Make labels concise but clear | Write overly long labels |
| Start with action verb for buttons | Include element type in label |
| Test with actual screen readers | Assume visual clarity = accessibility |
When to Use Each Attribute
| Situation | Use |
|---|---|
| Icon-only button | aria-label |
| Existing visual label nearby | aria-labelledby |
| Form field without visible label | aria-label |
| Additional help text | aria-describedby |
| Multiple nav landmarks | aria-label on each |
| Section with heading | aria-labelledby to heading |
Implementation
The Accessible Name Computation Algorithm
Priority Order
Browsers follow a specific algorithm (accname-1.2) to determine accessible names:
1. aria-labelledby (highest priority)
2. aria-label
3. Native labeling (<label>, <caption>, <legend>)
4. Element content (text nodes, alt text)
5. title attribute (lowest priority, avoid)
Understanding Precedence
<!-- aria-labelledby overrides everything -->
<button aria-labelledby="ext-label" aria-label="Ignored">
Also ignored
</button>
<span id="ext-label">This is announced</span>
<!-- Announced: "This is announced, button" -->
<!-- aria-label overrides content -->
<button aria-label="This is announced">
This is ignored
</button>
<!-- Announced: "This is announced, button" -->
<!-- Content is used when no ARIA -->
<button>This is announced</button>
<!-- Announced: "This is announced, button" -->
Name from Content
Some roles allow name from content (text inside element):
<!-- Roles that get name from content -->
<button>Submit Form</button> <!-- ✅ Works -->
<a href="#">Learn More</a> <!-- ✅ Works -->
<td>Cell value</td> <!-- ✅ Works -->
<h1>Page Title</h1> <!-- ✅ Works -->
<!-- Roles that DON'T get name from content -->
<div role="img">Description here</div> <!-- ❌ Needs aria-label -->
<nav>Navigation</nav> <!-- ❌ Text ignored for name -->
<section>Section text</section> <!-- ❌ Text ignored for name -->
Implementing aria-label
Basic Usage
<!-- Icon buttons -->
<button aria-label="Close" class="close-btn">
<svg aria-hidden="true">
<use href="#icon-close"></use>
</svg>
</button>
<!-- Search without visible label -->
<div class="search-container">
<input type="search" aria-label="Search products">
<button aria-label="Submit search">
<svg aria-hidden="true"><!-- search icon --></svg>
</button>
</div>
<!-- Navigation landmarks -->
<nav aria-label="Main">...</nav>
<nav aria-label="Footer">...</nav>
<nav aria-label="Breadcrumb">...</nav>
Dynamic aria-label
// React example
function ToggleButton({ isActive, label }) {
return (
<button
aria-label={`${isActive ? 'Disable' : 'Enable'} ${label}`}
aria-pressed={isActive}
onClick={toggle}
>
<Icon name={isActive ? 'check' : 'circle'} />
</button>
);
}
// Vue 3 example
<template>
<button
:aria-label="`${isActive ? 'Desactivar' : 'Activar'} ${label}`"
:aria-pressed="isActive"
@click="toggle"
>
<Icon :name="isActive ? 'check' : 'circle'" />
</button>
</template>
When NOT to Use aria-label
<!-- DON'T: On elements that already have visible text -->
<button aria-label="Submit">Submit</button>
<!-- Redundant - just use content -->
<!-- DON'T: Different from visible text -->
<button aria-label="Send message">Submit</button>
<!-- Confuses voice control users -->
<!-- DON'T: On non-interactive elements -->
<p aria-label="Important paragraph">Text here</p>
<!-- Screen readers may ignore -->
<!-- DON'T: To add long descriptions -->
<button aria-label="Click this button to submit the form and you will receive a confirmation email within 24 hours">
Submit
</button>
<!-- Use aria-describedby for long descriptions -->
Implementing aria-labelledby
Referencing Existing Text
<!-- Single reference -->
<h2 id="cart-heading">Shopping Cart (3 items)</h2>
<section aria-labelledby="cart-heading">
<!-- Cart contents -->
</section>
<!-- Multiple references (space-separated) -->
<span id="fname-label">First Name</span>
<span id="fname-required">(required)</span>
<span id="fname-format">Letters only</span>
<input
type="text"
aria-labelledby="fname-label fname-required"
aria-describedby="fname-format"
>
<!-- Name: "First Name (required)" -->
<!-- Description: "Letters only" -->
Self-Reference Pattern
<!-- Include element's own content in label -->
<button
id="buy-btn"
aria-labelledby="buy-btn product-name"
>
Buy
</button>
<span id="product-name">Wireless Mouse</span>
<!-- Announced: "Buy Wireless Mouse, button" -->
Dialog Labeling
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">
Are you sure you want to delete this item?
This action cannot be undone.
</p>
<button>Cancel</button>
<button>Delete</button>
</div>
Table and Grid Labeling
<!-- Table with caption -->
<table aria-labelledby="table-caption">
<caption id="table-caption">Quarterly Sales Report 2024</caption>
<thead>...</thead>
<tbody>...</tbody>
</table>
<!-- Grid with external heading -->
<h2 id="grid-title">Product Comparison</h2>
<div role="grid" aria-labelledby="grid-title">
<div role="row">
<div role="columnheader">Feature</div>
<div role="columnheader">Basic</div>
<div role="columnheader">Pro</div>
</div>
<!-- rows -->
</div>
Implementing aria-describedby
Form Field Help Text
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
aria-describedby="username-help username-error"
>
<p id="username-help" class="help-text">
3-20 characters, letters and numbers only
</p>
<p id="username-error" class="error" hidden>
Username already taken
</p>
</div>
// Show error when validation fails
function showError(input, errorElement) {
errorElement.hidden = false;
input.setAttribute('aria-invalid', 'true');
// aria-describedby already includes error ID
}
Complex Widget Descriptions
<!-- Slider with description -->
<div class="slider-container">
<label id="volume-label">Volume</label>
<div
role="slider"
aria-labelledby="volume-label"
aria-describedby="volume-desc"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
tabindex="0"
>
<!-- slider track and thumb -->
</div>
<p id="volume-desc">
Use arrow keys to adjust. Current value shown as percentage.
</p>
</div>
Component-Specific Patterns
Accessible Icon Buttons
<!-- Simple icon button -->
<button aria-label="Settings" class="icon-btn">
<svg aria-hidden="true" focusable="false">
<use href="#icon-settings"></use>
</svg>
</button>
<!-- Icon button with badge -->
<button aria-label="Notifications, 5 unread" class="icon-btn">
<svg aria-hidden="true" focusable="false">
<use href="#icon-bell"></use>
</svg>
<span class="badge" aria-hidden="true">5</span>
</button>
<!-- Toggle icon button -->
<button
aria-label="Bookmark"
aria-pressed="false"
class="icon-btn"
>
<svg aria-hidden="true" focusable="false">
<use href="#icon-bookmark"></use>
</svg>
</button>
Accessible Cards
<article class="card" aria-labelledby="card-1-title">
<img src="product.jpg" alt="">
<h3 id="card-1-title">Wireless Headphones</h3>
<p>High-quality audio with 30-hour battery life.</p>
<p class="price">$149.99</p>
<a
href="/products/wireless-headphones"
aria-label="View Wireless Headphones details"
>
View Details
</a>
</article>
Accessible Tabs
<div class="tabs-container">
<div role="tablist" aria-label="Account Settings">
<button
role="tab"
id="tab-profile"
aria-selected="true"
aria-controls="panel-profile"
>
Profile
</button>
<button
role="tab"
id="tab-security"
aria-selected="false"
aria-controls="panel-security"
tabindex="-1"
>
Security
</button>
<button
role="tab"
id="tab-notifications"
aria-selected="false"
aria-controls="panel-notifications"
tabindex="-1"
>
Notifications
</button>
</div>
<div
role="tabpanel"
id="panel-profile"
aria-labelledby="tab-profile"
>
<!-- Profile content -->
</div>
</div>
Accessible Accordion
<div class="accordion">
<h3>
<button
aria-expanded="true"
aria-controls="section1-content"
id="section1-header"
>
Shipping Information
</button>
</h3>
<div
id="section1-content"
role="region"
aria-labelledby="section1-header"
>
<p>We ship worldwide with free shipping on orders over $50.</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="section2-content"
id="section2-header"
>
Return Policy
</button>
</h3>
<div
id="section2-content"
role="region"
aria-labelledby="section2-header"
hidden
>
<p>Returns accepted within 30 days of purchase.</p>
</div>
</div>
Framework Integration
React Patterns
// Accessible button component
interface IconButtonProps {
icon: string;
label: string;
onClick: () => void;
badge?: number;
}
function IconButton({ icon, label, onClick, badge }: IconButtonProps) {
const computedLabel = badge
? `${label}, ${badge} ${badge === 1 ? 'item' : 'items'}`
: label;
return (
<button
aria-label={computedLabel}
onClick={onClick}
className="icon-btn"
>
<Icon name={icon} aria-hidden="true" />
{badge && (
<span className="badge" aria-hidden="true">{badge}</span>
)}
</button>
);
}
// Usage
<IconButton
icon="cart"
label="Shopping cart"
badge={3}
onClick={openCart}
/>
Vue 3 Patterns
<!-- AccessibleDialog.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
title: string
description?: string
}>()
const titleId = computed(() => `dialog-title-${crypto.randomUUID()}`)
const descId = computed(() => `dialog-desc-${crypto.randomUUID()}`)
</script>
<template>
<div
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="description ? descId : undefined"
>
<h2 :id="titleId">{{ title }}</h2>
<p v-if="description" :id="descId">{{ description }}</p>
<slot />
</div>
</template>
Testing ARIA Labels
Automated Testing
// Jest + Testing Library
import { render, screen } from '@testing-library/react';
test('icon button has accessible name', () => {
render(<IconButton icon="close" label="Close dialog" onClick={jest.fn()} />);
const button = screen.getByRole('button', { name: 'Close dialog' });
expect(button).toBeInTheDocument();
});
test('dialog is properly labeled', () => {
render(<Dialog title="Confirm Action" description="Are you sure?" />);
const dialog = screen.getByRole('dialog', { name: 'Confirm Action' });
expect(dialog).toHaveAccessibleDescription('Are you sure?');
});
Playwright Accessibility Testing
// a11y.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test('all buttons have accessible names', async ({ page }) => {
await page.goto('/');
const buttons = await page.locator('button').all();
for (const button of buttons) {
const name = await button.getAttribute('aria-label') ||
await button.textContent();
expect(name?.trim()).toBeTruthy();
}
});
test('no axe violations for labeling', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['button-name', 'link-name', 'image-alt'])
.analyze();
expect(results.violations).toHaveLength(0);
});
Common Pitfalls and Solutions
Pitfall 1: Empty References
<!-- BAD: Reference doesn't exist -->
<button aria-labelledby="nonexistent">Click</button>
<!-- Name is empty! -->
<!-- GOOD: Ensure reference exists -->
<span id="btn-label" class="sr-only">Submit form</span>
<button aria-labelledby="btn-label">
<svg>...</svg>
</button>
Pitfall 2: Overusing ARIA
<!-- BAD: ARIA where not needed -->
<button aria-label="Submit" role="button">Submit</button>
<!-- GOOD: Native elements are already accessible -->
<button>Submit</button>
Pitfall 3: Inconsistent Labeling
<!-- BAD: Same action, different labels -->
<button aria-label="Close">X</button>
<button aria-label="Dismiss">X</button>
<button aria-label="Exit">X</button>
<!-- GOOD: Consistent labeling -->
<button aria-label="Close">X</button>
<button aria-label="Close">X</button>
<button aria-label="Close">X</button>
Summary Checklist
Implementation Checklist
- [ ] All interactive elements have accessible names
- [ ] Icon-only buttons use
aria-label - [ ] Complex widgets use
aria-labelledbyfor visible labels - [ ] Help text uses
aria-describedby - [ ] Multiple nav landmarks are distinguished
- [ ] Dialogs have title and description
- [ ] No redundant labeling
- [ ] Labels match visible text when present
- [ ] Dynamic labels update appropriately
- [ ] Tested with screen readers
Related Articles
- Form Labels Guide - Native labeling
- Accessible Components Hub - Full guide
References
- W3C - WCAG 2.2 SC 4.1.2 Name, Role, Value
- W3C - Accessible Name Computation
- WebAIM - ARIA
- MDN - Using aria-label
- W3C - ARIA Authoring Practices
- Deque - Accessible Name Calculation