Ver contenido
Guía de Implementación de Etiquetas ARIA: Patrones Completos para Nombres Accesibles
Introducción
El etiquetado ARIA es más que solo agregar aria-label a elementos. Requiere entender cómo los navegadores calculan nombres accesibles, qué técnicas funcionan para diferentes contextos, y cómo evitar errores comunes que empeoran la accesibilidad en lugar de mejorarla. Esta guía proporciona patrones de implementación completos para todos los escenarios de etiquetado ARIA.
El nombre accesible es lo que los lectores de pantalla anuncian cuando los usuarios enfocan un elemento. Hacerlo bien es crítico—un nombre faltante o poco claro hace los elementos inutilizables para usuarios de lectores de pantalla.
El Algoritmo de Cálculo del Nombre Accesible
Orden de Prioridad
Los navegadores siguen un algoritmo específico (accname-1.2) para determinar nombres accesibles:
1. aria-labelledby (prioridad más alta)
2. aria-label
3. Etiquetado nativo (<label>, <caption>, <legend>)
4. Contenido del elemento (nodos de texto, texto alt)
5. Atributo title (prioridad más baja, evitar)
Entendiendo la Precedencia
<!-- aria-labelledby sobrescribe todo -->
<button aria-labelledby="ext-label" aria-label="Ignorado">
También ignorado
</button>
<span id="ext-label">Esto se anuncia</span>
<!-- Anunciado: "Esto se anuncia, botón" -->
<!-- aria-label sobrescribe contenido -->
<button aria-label="Esto se anuncia">
Esto se ignora
</button>
<!-- Anunciado: "Esto se anuncia, botón" -->
<!-- El contenido se usa cuando no hay ARIA -->
<button>Esto se anuncia</button>
<!-- Anunciado: "Esto se anuncia, botón" -->
Nombre desde el Contenido
Algunos roles permiten nombre desde el contenido (texto dentro del elemento):
<!-- Roles que obtienen nombre del contenido -->
<button>Enviar Formulario</button> <!-- ✅ Funciona -->
<a href="#">Saber Más</a> <!-- ✅ Funciona -->
<td>Valor de celda</td> <!-- ✅ Funciona -->
<h1>Título de Página</h1> <!-- ✅ Funciona -->
<!-- Roles que NO obtienen nombre del contenido -->
<div role="img">Descripción aquí</div> <!-- ❌ Necesita aria-label -->
<nav>Navegación</nav> <!-- ❌ Texto ignorado para nombre -->
<section>Texto de sección</section> <!-- ❌ Texto ignorado para nombre -->
Implementando aria-label
Uso Básico
<!-- Botones con íconos -->
<button aria-label="Cerrar" class="close-btn">
<svg aria-hidden="true">
<use href="#icon-close"></use>
</svg>
</button>
<!-- Búsqueda sin etiqueta visible -->
<div class="search-container">
<input type="search" aria-label="Buscar productos">
<button aria-label="Enviar búsqueda">
<svg aria-hidden="true"><!-- ícono buscar --></svg>
</button>
</div>
<!-- Landmarks de navegación -->
<nav aria-label="Principal">...</nav>
<nav aria-label="Pie de página">...</nav>
<nav aria-label="Migas de pan">...</nav>
aria-label Dinámico
// Ejemplo React
function ToggleButton({ isActive, label }) {
return (
<button
aria-label={`${isActive ? 'Desactivar' : 'Activar'} ${label}`}
aria-pressed={isActive}
onClick={toggle}
>
<Icon name={isActive ? 'check' : 'circle'} />
</button>
);
}
// Ejemplo Vue 3
<template>
<button
:aria-label="`${isActive ? 'Desactivar' : 'Activar'} ${label}`"
:aria-pressed="isActive"
@click="toggle"
>
<Icon :name="isActive ? 'check' : 'circle'" />
</button>
</template>
Cuándo NO Usar aria-label
<!-- NO: En elementos que ya tienen texto visible -->
<button aria-label="Enviar">Enviar</button>
<!-- Redundante - solo usa el contenido -->
<!-- NO: Diferente del texto visible -->
<button aria-label="Enviar mensaje">Enviar</button>
<!-- Confunde a usuarios de control por voz -->
<!-- NO: En elementos no interactivos -->
<p aria-label="Párrafo importante">Texto aquí</p>
<!-- Lectores de pantalla pueden ignorar -->
<!-- NO: Para agregar descripciones largas -->
<button aria-label="Haz click en este botón para enviar el formulario y recibirás un correo de confirmación dentro de 24 horas">
Enviar
</button>
<!-- Usa aria-describedby para descripciones largas -->
Implementando aria-labelledby
Referenciando Texto Existente
<!-- Referencia única -->
<h2 id="cart-heading">Carrito de Compras (3 artículos)</h2>
<section aria-labelledby="cart-heading">
<!-- Contenido del carrito -->
</section>
<!-- Múltiples referencias (separadas por espacio) -->
<span id="fname-label">Nombre</span>
<span id="fname-required">(requerido)</span>
<span id="fname-format">Solo letras</span>
<input
type="text"
aria-labelledby="fname-label fname-required"
aria-describedby="fname-format"
>
<!-- Nombre: "Nombre (requerido)" -->
<!-- Descripción: "Solo letras" -->
Patrón de Auto-Referencia
<!-- Incluir el contenido propio del elemento en la etiqueta -->
<button
id="buy-btn"
aria-labelledby="buy-btn product-name"
>
Comprar
</button>
<span id="product-name">Mouse Inalámbrico</span>
<!-- Anunciado: "Comprar Mouse Inalámbrico, botón" -->
Etiquetado de Diálogos
<div
role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirmar Eliminación</h2>
<p id="dialog-desc">
¿Estás seguro de que quieres eliminar este elemento?
Esta acción no se puede deshacer.
</p>
<button>Cancelar</button>
<button>Eliminar</button>
</div>
Etiquetado de Tablas y Grillas
<!-- Tabla con caption -->
<table aria-labelledby="table-caption">
<caption id="table-caption">Informe de Ventas Trimestral 2024</caption>
<thead>...</thead>
<tbody>...</tbody>
</table>
<!-- Grilla con encabezado externo -->
<h2 id="grid-title">Comparación de Productos</h2>
<div role="grid" aria-labelledby="grid-title">
<div role="row">
<div role="columnheader">Característica</div>
<div role="columnheader">Básico</div>
<div role="columnheader">Pro</div>
</div>
<!-- filas -->
</div>
Implementando aria-describedby
Texto de Ayuda en Campos de Formulario
<div class="form-group">
<label for="username">Nombre de Usuario</label>
<input
type="text"
id="username"
aria-describedby="username-help username-error"
>
<p id="username-help" class="help-text">
3-20 caracteres, solo letras y números
</p>
<p id="username-error" class="error" hidden>
El nombre de usuario ya está en uso
</p>
</div>
// Mostrar error cuando falla la validación
function showError(input, errorElement) {
errorElement.hidden = false;
input.setAttribute('aria-invalid', 'true');
// aria-describedby ya incluye el ID del error
}
Descripciones de Widgets Complejos
<!-- Slider con descripción -->
<div class="slider-container">
<label id="volume-label">Volumen</label>
<div
role="slider"
aria-labelledby="volume-label"
aria-describedby="volume-desc"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
tabindex="0"
>
<!-- track y thumb del slider -->
</div>
<p id="volume-desc">
Usa las teclas de flecha para ajustar. El valor actual se muestra como porcentaje.
</p>
</div>
Patrones Específicos por Componente
Botones con Íconos Accesibles
<!-- Botón simple con ícono -->
<button aria-label="Configuración" class="icon-btn">
<svg aria-hidden="true" focusable="false">
<use href="#icon-settings"></use>
</svg>
</button>
<!-- Botón con ícono y badge -->
<button aria-label="Notificaciones, 5 sin leer" class="icon-btn">
<svg aria-hidden="true" focusable="false">
<use href="#icon-bell"></use>
</svg>
<span class="badge" aria-hidden="true">5</span>
</button>
<!-- Botón toggle con ícono -->
<button
aria-label="Marcar favorito"
aria-pressed="false"
class="icon-btn"
>
<svg aria-hidden="true" focusable="false">
<use href="#icon-bookmark"></use>
</svg>
</button>
Cards Accesibles
<article class="card" aria-labelledby="card-1-title">
<img src="product.jpg" alt="">
<h3 id="card-1-title">Auriculares Inalámbricos</h3>
<p>Audio de alta calidad con 30 horas de batería.</p>
<p class="price">$149.99</p>
<a
href="/productos/auriculares-inalambricos"
aria-label="Ver detalles de Auriculares Inalámbricos"
>
Ver Detalles
</a>
</article>
Tabs Accesibles
<div class="tabs-container">
<div role="tablist" aria-label="Configuración de Cuenta">
<button
role="tab"
id="tab-profile"
aria-selected="true"
aria-controls="panel-profile"
>
Perfil
</button>
<button
role="tab"
id="tab-security"
aria-selected="false"
aria-controls="panel-security"
tabindex="-1"
>
Seguridad
</button>
<button
role="tab"
id="tab-notifications"
aria-selected="false"
aria-controls="panel-notifications"
tabindex="-1"
>
Notificaciones
</button>
</div>
<div
role="tabpanel"
id="panel-profile"
aria-labelledby="tab-profile"
>
<!-- Contenido del perfil -->
</div>
</div>
Acordeón Accesible
<div class="accordion">
<h3>
<button
aria-expanded="true"
aria-controls="section1-content"
id="section1-header"
>
Información de Envío
</button>
</h3>
<div
id="section1-content"
role="region"
aria-labelledby="section1-header"
>
<p>Enviamos a todo el mundo con envío gratis en pedidos mayores a $50.</p>
</div>
<h3>
<button
aria-expanded="false"
aria-controls="section2-content"
id="section2-header"
>
Política de Devolución
</button>
</h3>
<div
id="section2-content"
role="region"
aria-labelledby="section2-header"
hidden
>
<p>Se aceptan devoluciones dentro de 30 días de la compra.</p>
</div>
</div>
Integración con Frameworks
Patrones React
// Componente de botón accesible
interface IconButtonProps {
icon: string;
label: string;
onClick: () => void;
badge?: number;
}
function IconButton({ icon, label, onClick, badge }: IconButtonProps) {
const computedLabel = badge
? `${label}, ${badge} ${badge === 1 ? 'elemento' : 'elementos'}`
: 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>
);
}
// Uso
<IconButton
icon="cart"
label="Carrito de compras"
badge={3}
onClick={openCart}
/>
Patrones Vue 3
<!-- 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>
Probando Etiquetas ARIA
Pruebas Automatizadas
// Jest + Testing Library
import { render, screen } from '@testing-library/react';
test('botón con ícono tiene nombre accesible', () => {
render(<IconButton icon="close" label="Cerrar diálogo" onClick={jest.fn()} />);
const button = screen.getByRole('button', { name: 'Cerrar diálogo' });
expect(button).toBeInTheDocument();
});
test('diálogo está correctamente etiquetado', () => {
render(<Dialog title="Confirmar Acción" description="¿Estás seguro?" />);
const dialog = screen.getByRole('dialog', { name: 'Confirmar Acción' });
expect(dialog).toHaveAccessibleDescription('¿Estás seguro?');
});
Pruebas de Accesibilidad con Playwright
// a11y.spec.js
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test('todos los botones tienen nombres accesibles', 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('sin violaciones axe para etiquetado', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withRules(['button-name', 'link-name', 'image-alt'])
.analyze();
expect(results.violations).toHaveLength(0);
});
Errores Comunes y Soluciones
Error 1: Referencias Vacías
<!-- MAL: La referencia no existe -->
<button aria-labelledby="nonexistent">Click</button>
<!-- ¡El nombre está vacío! -->
<!-- BIEN: Asegurar que la referencia existe -->
<span id="btn-label" class="sr-only">Enviar formulario</span>
<button aria-labelledby="btn-label">
<svg>...</svg>
</button>
Error 2: Uso Excesivo de ARIA
<!-- MAL: ARIA donde no es necesario -->
<button aria-label="Enviar" role="button">Enviar</button>
<!-- BIEN: Elementos nativos ya son accesibles -->
<button>Enviar</button>
Error 3: Etiquetado Inconsistente
<!-- MAL: Misma acción, diferentes etiquetas -->
<button aria-label="Cerrar">X</button>
<button aria-label="Descartar">X</button>
<button aria-label="Salir">X</button>
<!-- BIEN: Etiquetado consistente -->
<button aria-label="Cerrar">X</button>
<button aria-label="Cerrar">X</button>
<button aria-label="Cerrar">X</button>
Checklist de Resumen
Checklist de Implementación
- [ ] Todos los elementos interactivos tienen nombres accesibles
- [ ] Botones solo con íconos usan
aria-label - [ ] Widgets complejos usan
aria-labelledbypara etiquetas visibles - [ ] Texto de ayuda usa
aria-describedby - [ ] Múltiples landmarks de navegación están diferenciados
- [ ] Diálogos tienen título y descripción
- [ ] Sin etiquetado redundante
- [ ] Etiquetas coinciden con texto visible cuando está presente
- [ ] Etiquetas dinámicas se actualizan apropiadamente
- [ ] Probado con lectores de pantalla
Referencias
- 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