Saltar al contenido principal
Volver al blog

Accesibilidad web (a11y) con React y Tailwind CSS

Ray MartínRay Martín
9 min de lectura
Accesibilidad web (a11y) con React y Tailwind CSS

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

tsx
// 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):

tsx
// ✅ 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.

tsx
"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>
  );
}

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

tsx
// 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:

tsx
"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:

tsx
"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

tsx
"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

tsx
// 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

tsx
// 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:

tsx
// ✅ 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:

  1. Navega solo con teclado: Tab, Shift+Tab, Enter, Escape, flechas. ¿Puedes acceder a todo?
  2. Activa un lector de pantalla: VoiceOver (Mac), NVDA (Windows), Orca (Linux). ¿El contenido tiene sentido?
  3. Zoom al 200%: ¿Se rompe el layout? ¿Se pierde contenido?
  4. Desactiva imágenes: ¿Los textos alternativos son descriptivos?
  5. Modo de alto contraste: ¿Los elementos siguen siendo distinguibles?

ESLint para accesibilidad

bash
npm install --save-dev eslint-plugin-jsx-a11y
json
// .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.

Compartir:

Artículos relacionados