Accesibilidad en Sistemas de Diseño
Construye componentes inclusivos con cumplimiento WCAG, patrones ARIA y navegación por teclado
Por Qué la Accesibilidad es No Negociable
Los sistemas de diseño son el lugar perfecto para incorporar accesibilidad. Cuando tus componentes base son accesibles, cada producto construido sobre ellos hereda esa accesibilidad. Hazlo bien una vez, benefíciate en todas partes.
♿ Referencia Rápida WCAG 2.1
Alternativas de texto, subtítulos, contraste de color
Acceso por teclado, sin convulsiones, navegable
Legible, predecible, asistencia de entrada
Compatible con tecnologías asistivas
Requisitos de Contraste de Color
// Requisitos de Contraste WCAG 2.1:
// - AA: 4.5:1 para texto normal, 3:1 para texto grande (18px+ o 14px negrita)
// - AAA: 7:1 para texto normal, 4.5:1 para texto grande
// Usa la biblioteca colord para verificar contraste
import { colord, extend } from 'colord';
import a11yPlugin from 'colord/plugins/a11y';
extend([a11yPlugin]);
function checkContrast(foreground: string, background: string) {
const ratio = colord(foreground).contrast(background);
return {
ratio: ratio.toFixed(2),
passesAA: ratio >= 4.5,
passesAALarge: ratio >= 3,
passesAAA: ratio >= 7,
};
}
// Prueba tus tokens de diseño
checkContrast('#ffffff', '#3b82f6'); // { ratio: '4.68', passesAA: true }
checkContrast('#6b7280', '#ffffff'); // { ratio: '4.69', passesAA: true }
// Herramientas:
// - Verificador de Contraste WebAIM: https://webaim.org/resources/contrastchecker/
// - Stark (plugin Figma): https://www.getstark.co/
Navegación por Teclado
// Cada elemento interactivo debe ser accesible por teclado
// Usa HTML semántico primero, agrega manejadores de teclado personalizados cuando sea necesario
import { useCallback, KeyboardEvent } from 'react';
// Hook personalizado para tabindex itinerante (menús, barras de herramientas)
function useRovingTabindex<T extends HTMLElement>(items: T[]) {
const [focusedIndex, setFocusedIndex] = useState(0);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
setFocusedIndex((i) => (i + 1) % items.length);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
setFocusedIndex((i) => (i - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setFocusedIndex(0);
break;
case 'End':
e.preventDefault();
setFocusedIndex(items.length - 1);
break;
}
}, [items.length]);
return { focusedIndex, handleKeyDown };
}
// Ejemplo de dropdown accesible
function Dropdown({ options, onSelect }) {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<div>
<button
ref={buttonRef}
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setIsOpen(false);
buttonRef.current?.focus();
}
}}
>
Seleccionar opción
</button>
{isOpen && (
<ul role="listbox" aria-label="Opciones">
{options.map((opt, i) => (
<li
key={opt.value}
role="option"
tabIndex={0}
onClick={() => onSelect(opt)}
onKeyDown={(e) => e.key === 'Enter' && onSelect(opt)}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
Patrones ARIA
// Usa ARIA solo cuando la semántica HTML no es suficiente
// "No ARIA es mejor que ARIA malo"
// ❌ Malo: Usar ARIA cuando HTML funciona
<div role="button" tabindex="0" onclick="...">Haz clic en mí</div>
// ✅ Bueno: Usa HTML nativo
<button onclick="...">Haz clic en mí</button>
// Patrones ARIA comunes para sistemas de diseño:
// 1. Diálogo Modal
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
>
<h2 id="dialog-title">Eliminar Elemento</h2>
<p id="dialog-description">¿Estás seguro de que quieres eliminar esto?</p>
<button>Cancelar</button>
<button>Eliminar</button>
</div>
// 2. Pestañas
<div role="tablist" aria-label="Pestañas de configuración">
<button role="tab" aria-selected="true" aria-controls="panel-1">General</button>
<button role="tab" aria-selected="false" aria-controls="panel-2">Privacidad</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
Contenido de configuración general
</div>
// 3. Alerta / Toast
<div role="alert" aria-live="polite">
Tus cambios han sido guardados.
</div>
// 4. Estado de carga
<button disabled aria-busy="true">
<span className="spinner" aria-hidden="true" />
Cargando...
</button>
// Referencia: Prácticas de Autoría WAI-ARIA
// https://www.w3.org/WAI/ARIA/apg/patterns/
Gestión del Foco
// useFocusTrap - mantiene el foco dentro de modales/diálogos
import { useEffect, useRef } from 'react';
function useFocusTrap<T extends HTMLElement>() {
const containerRef = useRef<T>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
// Enfocar primer elemento al montar
firstElement?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, []);
return containerRef;
}
// Uso en componente Dialog
function Dialog({ isOpen, onClose, children }) {
const dialogRef = useFocusTrap<HTMLDivElement>();
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previouslyFocused.current = document.activeElement as HTMLElement;
} else {
previouslyFocused.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={dialogRef} role="dialog" aria-modal="true">
{children}
</div>
);
}
Pruebas con Lector de Pantalla
// ¡Probar con lectores de pantalla es esencial!
// macOS: VoiceOver (incorporado)
// - Cmd + F5 para habilitar
// - Usa teclas VO: Ctrl + Opción
// Windows: NVDA (gratis)
// - Descarga desde https://www.nvaccess.org/
// - La tecla Insert es el modificador NVDA
// Pruebas automatizadas con axe-core
npm install @axe-core/react
// Configuración en desarrollo
import React from 'react';
import ReactDOM from 'react-dom';
if (process.env.NODE_ENV !== 'production') {
import('@axe-core/react').then((axe) => {
axe.default(React, ReactDOM, 1000);
// Reporta problemas de accesibilidad a la consola
});
}
// Complemento de accesibilidad de Storybook
npm install @storybook/addon-a11y
// .storybook/main.js
module.exports = {
addons: ['@storybook/addon-a11y'],
};
// ¡Ejecuta axe en cada historia automáticamente!
// jest-axe para pruebas unitarias
npm install jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button es accesible', async () => {
const { container } = render(<Button>Haz clic en mí</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Bibliotecas de Sistemas de Diseño Accesibles
Primitivas sin estilo y accesibles con soporte completo de teclado
Hooks para primitivas de interfaz accesibles, extremadamente completos
Componentes sin estilo y accesibles de Tailwind Labs
Primitivas de autocompletar/dropdown accesibles
✅ Lista de Verificación de Accesibilidad
- ☐ Todos los elementos interactivos son accesibles por teclado
- ☐ Los estados de foco son visibles y claros
- ☐ El contraste de color cumple WCAG AA (4.5:1)
- ☐ Las imágenes tienen texto alt, imágenes decorativas usan alt=""
- ☐ Los inputs de formulario tienen etiquetas asociadas
- ☐ Los mensajes de error se anuncian a lectores de pantalla
- ☐ La página tiene jerarquía de encabezados adecuada (h1 → h2 → h3)
- ☐ El foco está atrapado en modales/diálogos
- ☐ Se proporcionan enlaces de salto para navegación