View contents
ARIA Labels Implementation Guide: Complete Patterns for Accessible Naming
Introduction
ARIA labeling is more than just adding aria-label to elements. It requires understanding how browsers calculate accessible names, which techniques work for different contexts, and how to avoid common pitfalls that make accessibility worse instead of better. This guide provides comprehensive implementation patterns for all ARIA labeling scenarios.
The accessible name is what screen readers announce when users focus on an element. Getting this right is critical—a missing or unclear name makes elements unusable for screen reader users.
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
References
- W3C - WCAG 2.2 SC 4.1.2 Name, Role, Value
- W3C - Accessible Name and Description Computation
- W3C - ARIA Authoring Practices
- WebAIM - ARIA Techniques
- Deque - Accessible Name Calculation