La accesibilidad web (a11y) no es un extra opcional — es un requisito legal en muchos países y una responsabilidad ética para cualquier desarrollador. Con React y Tailwind CSS, puedes construir interfaces accesibles sin sacrificar velocidad de desarrollo ni diseño. Este artículo te muestra cómo cumplir con WCAG 2.2 en tu proyecto Next.js.
¿Por qué importa la accesibilidad?
Más del 15% de la población mundial vive con algún tipo de discapacidad. Pero la accesibilidad no solo beneficia a personas con discapacidades permanentes:
- Discapacidades temporales: Un brazo roto, una infección ocular.
- Discapacidades situacionales: Usar el móvil con una sola mano, pantalla con sol directo.
- Conexiones lentas: Usuarios en zonas rurales o con datos limitados.
- SEO: Los crawlers de Google son esencialmente "usuarios ciegos" — el HTML semántico les ayuda.
- Legal: La European Accessibility Act (EAA) entra en vigor plenamente en junio de 2025.
HTML semántico: la base de todo
El primer paso para una web accesible es usar el elemento HTML correcto para cada propósito. Los lectores de pantalla y las tecnologías asistivas dependen de la semántica del HTML.
Landmarks y estructura
// app/[locale]/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<body>
{/* Skip link — primer elemento del DOM */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:rounded-lg focus:bg-primary-600 focus:px-4
focus:py-2 focus:text-white"
>
Saltar al contenido principal
</a>
<header role="banner">
<nav aria-label="Navegación principal">
{/* Navbar */}
</nav>
</header>
<main id="main-content" role="main">
{children}
</main>
<footer role="contentinfo">
{/* Footer */}
</footer>
</body>
</html>
);
}Jerarquía de headings
Los headings deben seguir una jerarquía lógica. Nunca saltes niveles (de h1 a h3 sin h2):
// ✅ Correcto: jerarquía lógica
<main>
<h1>Servicios de Desarrollo Web</h1>
<section aria-labelledby="frontend-heading">
<h2 id="frontend-heading">Frontend</h2>
<h3>React y Next.js</h3>
<p>Desarrollo de interfaces modernas...</p>
<h3>Accesibilidad</h3>
<p>Cumplimiento WCAG 2.2...</p>
</section>
<section aria-labelledby="backend-heading">
<h2 id="backend-heading">Backend</h2>
<p>APIs y microservicios...</p>
</section>
</main>
// ❌ Incorrecto: salta de h1 a h3
<h1>Servicios</h1>
<h3>Frontend</h3> {/* Falta h2 */}Formularios accesibles
Los formularios son uno de los elementos más críticos en accesibilidad. Cada campo debe tener un label asociado, mensajes de error claros y atributos ARIA correctos.
"use client";
import { useForm } from "react-hook-form";
import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
name: z.string().min(2, "El nombre debe tener al menos 2 caracteres"),
email: z.email("Introduce un email válido"),
message: z.string().min(10, "El mensaje debe tener al menos 10 caracteres"),
});
type FormData = z.infer<typeof schema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({ resolver: zodResolver(schema) });
return (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
aria-label="Formulario de contacto"
>
{/* Campo con label, descripción y error */}
<div>
<label htmlFor="contact-name">
Nombre <span aria-hidden="true">*</span>
</label>
<input
id="contact-name"
type="text"
{...register("name")}
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={
errors.name ? "name-error" : "name-help"
}
autoComplete="name"
/>
<p id="name-help" className="text-sm text-gray-500">
Tu nombre completo
</p>
{errors.name && (
<p id="name-error" role="alert" className="text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
{/* Campo email */}
<div>
<label htmlFor="contact-email">
Email <span aria-hidden="true">*</span>
</label>
<input
id="contact-email"
type="email"
{...register("email")}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
autoComplete="email"
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
{/* Textarea con contador */}
<div>
<label htmlFor="contact-message">
Mensaje <span aria-hidden="true">*</span>
</label>
<textarea
id="contact-message"
{...register("message")}
rows={5}
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? "message-error" : undefined}
/>
{errors.message && (
<p id="message-error" role="alert" className="text-sm text-red-600">
{errors.message.message}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? "Enviando..." : "Enviar mensaje"}
</button>
</form>
);
}Navegación por teclado
Todo lo que un usuario puede hacer con ratón debe ser posible con teclado. Esto incluye navegación, formularios, modales y componentes interactivos.
Focus visible con Tailwind
// Estilos base para focus en tailwind.config.ts
// Tailwind incluye focus-visible por defecto
// Uso en componentes:
<button
className="rounded-lg bg-primary-600 px-6 py-3 text-white
hover:bg-primary-700
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-500 focus-visible:ring-offset-2"
>
Contactar
</button>
// Para links de navegación:
<a
href="/servicios"
className="text-gray-700 hover:text-primary-600
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-primary-500 focus-visible:rounded"
>
Servicios
</a>Focus trap en modales
Cuando un modal está abierto, el foco del teclado debe quedar atrapado dentro del modal. Con Headless UI o Radix UI esto viene incluido:
"use client";
import * as Dialog from "@radix-ui/react-dialog";
export function ContactModal() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<button className="rounded-lg bg-primary-600 px-6 py-3 text-white">
Abrir formulario
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content
className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
rounded-xl bg-white p-8 shadow-xl"
aria-describedby="modal-description"
>
<Dialog.Title className="text-xl font-bold">
Contacto
</Dialog.Title>
<Dialog.Description id="modal-description">
Rellena el formulario y te responderemos en 24h.
</Dialog.Description>
{/* El foco queda atrapado aquí automáticamente */}
<ContactForm />
<Dialog.Close asChild>
<button
className="absolute right-4 top-4"
aria-label="Cerrar modal"
>
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}Patrones ARIA comunes
Live regions para contenido dinámico
Cuando el contenido cambia dinámicamente (notificaciones, resultados de búsqueda, actualizaciones de estado), usa live regions para que los lectores de pantalla anuncien los cambios:
"use client";
import { useState } from "react";
export function SearchResults() {
const [results, setResults] = useState<string[]>([]);
const [isSearching, setIsSearching] = useState(false);
return (
<div>
<input
type="search"
aria-label="Buscar proyectos"
onChange={handleSearch}
/>
{/* Anuncia el número de resultados al lector de pantalla */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{isSearching
? "Buscando..."
: `${results.length} resultados encontrados`}
</div>
{/* Resultados visibles */}
<ul role="list">
{results.map((result) => (
<li key={result} role="listitem">{result}</li>
))}
</ul>
</div>
);
}Tabs accesibles
"use client";
import { useState } from "react";
const tabs = [
{ id: "frontend", label: "Frontend" },
{ id: "backend", label: "Backend" },
{ id: "devops", label: "DevOps" },
];
export function SkillsTabs() {
const [activeTab, setActiveTab] = useState("frontend");
return (
<div>
<div role="tablist" aria-label="Categorías de habilidades">
{tabs.map((tab) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === tab.id ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
className={`px-4 py-2 ${
activeTab === tab.id
? "border-b-2 border-primary-600 text-primary-600"
: "text-gray-500"
}`}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== tab.id}
tabIndex={0}
>
{/* Contenido del tab */}
</div>
))}
</div>
);
}Utilidades de accesibilidad con Tailwind
Texto solo para lectores de pantalla
// Texto invisible visualmente pero accesible
<span className="sr-only">Abrir menú de navegación</span>
// Visible solo con focus (para skip links)
<a href="#main" className="sr-only focus:not-sr-only">
Saltar al contenido
</a>Respetar prefers-reduced-motion
// Tailwind incluye el modificador motion-reduce
<div
className="transition-transform duration-300
motion-reduce:transition-none motion-reduce:transform-none"
data-aos="fade-up"
>
Contenido animado
</div>
// En CSS global para AOS:
// styles/globals.css
@media (prefers-reduced-motion: reduce) {
[data-aos] {
transition: none !important;
transform: none !important;
opacity: 1 !important;
}
}Contraste de colores
WCAG 2.2 requiere un ratio de contraste mínimo de 4.5:1 para texto normal y 3:1 para texto grande. Verifica tus colores:
// ✅ Buen contraste (primary-600 sobre blanco)
<p className="text-primary-600">Texto con buen contraste</p>
// ❌ Mal contraste (gray-300 sobre blanco)
<p className="text-gray-300">Texto casi invisible</p>
// ✅ Tailwind dark mode con buen contraste
<p className="text-gray-900 dark:text-gray-100">
Texto legible en ambos modos
</p>Testing de accesibilidad
Herramientas automáticas
- axe DevTools: Extensión de Chrome que detecta violaciones WCAG automáticamente.
- Lighthouse: Auditoría de accesibilidad integrada en Chrome DevTools.
- WAVE: Herramienta web que muestra errores de accesibilidad visualmente.
Testing manual imprescindible
Las herramientas automáticas solo detectan ~30% de los problemas de accesibilidad. El testing manual es imprescindible:
- Navega solo con teclado: Tab, Shift+Tab, Enter, Escape, flechas. ¿Puedes acceder a todo?
- Activa un lector de pantalla: VoiceOver (Mac), NVDA (Windows), Orca (Linux). ¿El contenido tiene sentido?
- Zoom al 200%: ¿Se rompe el layout? ¿Se pierde contenido?
- Desactiva imágenes: ¿Los textos alternativos son descriptivos?
- Modo de alto contraste: ¿Los elementos siguen siendo distinguibles?
ESLint para accesibilidad
npm install --save-dev eslint-plugin-jsx-a11y// .eslintrc.json
{
"extends": [
"next/core-web-vitals",
"plugin:jsx-a11y/recommended"
]
}Este plugin detecta en desarrollo errores como:
- Imágenes sin atributo
alt - Labels no asociados a inputs
- Click handlers sin soporte de teclado
- Atributos ARIA inválidos
- Heading levels que saltan niveles
Checklist WCAG 2.2 para desarrolladores React
- Perceptible: Texto alternativo en imágenes, subtítulos en videos, contraste de colores adecuado.
- Operable: Todo accesible por teclado, sin trampas de foco, suficiente tiempo para interactuar.
- Comprensible: Idioma del documento definido, labels en formularios, mensajes de error claros.
- Robusto: HTML válido, atributos ARIA correctos, compatible con tecnologías asistivas.
Conclusión
La accesibilidad web no es una feature que se añade al final — es una forma de pensar el desarrollo desde el principio. Con React, Tailwind CSS y las herramientas que hemos visto, puedes construir interfaces que funcionen para todos los usuarios sin comprometer la experiencia visual ni la velocidad de desarrollo.
Empieza por lo básico: HTML semántico, focus visible, labels en formularios y textos alternativos. A partir de ahí, añade patrones ARIA, live regions y testing manual. Cada pequeña mejora hace tu web más inclusiva.