Web Accessibility: WCAG 2.1 AA Compliance Guide

Build WCAG 2.1 AA compliant web apps with this practical guide. Covers semantic HTML, ARIA, keyboard navigation, color contrast, screen readers, and automated testing tools.

E
ECOSIRE Research and Development Team
|19 de marzo de 202611 min de lectura2.4k Palabras|

Parte de nuestra serie Compliance & Regulation

Leer la guía completa

Accesibilidad web: Guía de cumplimiento de WCAG 2.1 AA

La accesibilidad no es una característica que se agrega después del lanzamiento; es un atributo de calidad fundamental, al igual que el rendimiento o la seguridad. El cumplimiento de las WCAG 2.1 AA ahora es un requisito legal en la UE (Ley Europea de Accesibilidad, en vigor en junio de 2025), los EE. UU. (jurisprudencia del Título III de la ADA) y muchas otras jurisdicciones. Más allá del cumplimiento, las interfaces accesibles generan mejores conversiones, tienen una clasificación más alta en las búsquedas y atienden a aproximadamente 1.300 millones de personas con discapacidades en todo el mundo.

Esta guía es un manual de implementación práctica, no una lista de verificación. Aprenderá los cuatro principios WCAG, las técnicas más impactantes, cómo realizar pruebas sistemáticas y cómo integrar la accesibilidad en su flujo de trabajo de desarrollo de React/Next.js para que permanezca fijo.

Conclusiones clave

  • WCAG 2.1 AA requiere los cuatro principios POUR: Perceptible, Operable, Comprensible y Robusto
  • Comience con HTML semántico: proporciona un 70% de accesibilidad de forma gratuita antes de agregar cualquier ARIA
  • Relación mínima de contraste de color: 4,5:1 para texto normal, 3:1 para texto grande (18 pt/14 pt en negrita)
  • Cada elemento interactivo debe poder enfocarse mediante el teclado con un indicador de enfoque visible.
  • Los lectores de pantalla anuncian según el árbol de accesibilidad: prueba con NVDA (Windows) y VoiceOver (Mac).
  • ARIA es un último recurso: solo cambia la forma en que las tecnologías de asistencia interpretan el DOM, no el comportamiento.
  • Automatizar con axe-core en su proceso de CI; Las pruebas manuales detectan lo que la automatización pasa por alto
  • Documente su declaración de accesibilidad y proporcione un mecanismo de retroalimentación para que los usuarios informen problemas.

Los cuatro principios POUR

WCAG 2.1 está organizado en torno a cuatro principios. Cada criterio de éxito pertenece a uno de ellos.

Perceptible: la información debe estar presentable de manera que los usuarios puedan percibirla. Esto cubre alternativas de texto para imágenes, subtítulos para videos, suficiente contraste de color y contenido que no dependa únicamente del color para transmitir significado.

Operable: todas las funciones deben poder operarse mediante el teclado, con tiempo suficiente para interactuar, sin contenido que desencadene convulsiones y con una estructura navegable (omitir enlaces, títulos de páginas, orden de enfoque).

Comprensible: el contenido debe ser legible y predecible. Se debe identificar el lenguaje, los mensajes de error deben ser descriptivos y los formularios deben tener etiquetas claras y comentarios de validación.

Robusto: el contenido debe ser interpretable mediante tecnologías de asistencia actuales y futuras. Esto significa HTML válido, uso adecuado de ARIA y mensajes de estado que se anuncian sin necesidad de centrarse.


HTML semántico primero

El HTML semántico es la inversión en accesibilidad de mayor apalancamiento. Los elementos HTML nativos vienen con funciones de accesibilidad, estados y comportamiento del teclado integrados; no se requiere ARIA.

// BAD: Generic divs with no semantics
<div class="button" onclick="submit()">Submit</div>
<div class="nav">
  <div class="link" onclick="navigate('/home')">Home</div>
</div>

// GOOD: Native semantics, free keyboard and screen reader support
<button type="submit" onClick={submit}>Submit</button>
<nav aria-label="Main navigation">
  <a href="/home">Home</a>
</nav>

Las regiones emblemáticas ayudan a los usuarios de lectores de pantalla a navegar rápidamente saltando entre secciones:

// Every page should have these landmarks
<header>        {/* banner landmark */}
  <nav aria-label="Main">...</nav>
</header>
<main>           {/* main landmark */}
  <h1>Page Title</h1>
  <article>...</article>
  <aside aria-label="Related content">...</aside>
</main>
<footer>         {/* contentinfo landmark */}
  <nav aria-label="Footer">...</nav>
</footer>

La jerarquía de encabezados debe ser lógica e ininterrumpida:

// BAD: Skipped heading levels
<h1>Page Title</h1>
<h3>Section</h3>  {/* Skipped h2! */}

// GOOD: Sequential hierarchy
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>

Contraste de color

WCAG 2.1 AA requiere:

  • Relación de contraste 4,5:1 para texto normal (menos de 18 puntos/14 puntos en negrita)
  • Relación de contraste 3:1 para texto grande (18 puntos+ / 14 puntos+ negrita)
  • 3:1 para componentes de interfaz de usuario y objetos gráficos (botones, iconos, bordes de entrada)
// Tailwind color contrast examples
// FAIL: gray-400 on white (#9ca3af on #fff = 2.8:1)
<p className="text-gray-400">This fails AA</p>

// PASS: gray-700 on white (#374151 on #fff = 10.7:1)
<p className="text-gray-700">This passes AA</p>

// For dark mode, test both themes separately
<p className="text-gray-700 dark:text-gray-300">
  gray-700 on white (10.7:1) / gray-300 on gray-900 (9.2:1)
</p>

Utilice WebAIM Contrast Checker o la herramienta de contraste DevTools del navegador durante el desarrollo. Agregue esto a su Storybook o sistema de tokens de diseño para detectar regresiones:

// contrast-checker.ts
import { getContrast } from 'polished';

function assertContrast(fg: string, bg: string, level: 'AA' | 'AAA' = 'AA') {
  const ratio = getContrast(fg, bg);
  const required = level === 'AA' ? 4.5 : 7;
  if (ratio < required) {
    throw new Error(
      `Contrast ratio ${ratio.toFixed(2)}:1 fails WCAG ${level} (requires ${required}:1)`
    );
  }
}

Cada elemento interactivo (enlaces, botones, campos de formulario, widgets personalizados) debe ser accesible y operable mediante el teclado.

Gestión del enfoque

// Skip link: first element on every page
// Allows keyboard users to jump past navigation
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
                 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white
                 focus:rounded focus:ring-2 focus:ring-white"
    >
      Skip to main content
    </a>
  );
}

// Main content target
<main id="main-content" tabIndex={-1}>
  {/* tabIndex={-1} allows programmatic focus without appearing in tab order */}

Captura de enfoque en modales

Cuando se abre un cuadro de diálogo, el foco debe quedar atrapado dentro de él. Cuando se cierra, el foco vuelve al gatillo:

// focus-trap.tsx using @radix-ui/react-focus-trap (used internally by shadcn Dialog)
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useRef } from 'react';

export function AccessibleModal({ trigger, children, title }: Props) {
  const triggerRef = useRef<HTMLButtonElement>(null);

  return (
    <Dialog>
      <DialogTrigger ref={triggerRef} asChild>
        <button>Open</button>
      </DialogTrigger>
      <DialogContent
        // shadcn Dialog handles focus trap and returns focus to trigger on close
        aria-describedby="dialog-description"
      >
        <DialogTitle>{title}</DialogTitle>
        <p id="dialog-description" className="sr-only">
          {/* Screen reader description of dialog purpose */}
        </p>
        {children}
      </DialogContent>
    </Dialog>
  );
}

Indicadores de enfoque visibles

WCAG 2.1 SC 2.4.11 (AA en WCAG 2.2) requiere un contorno de enfoque mínimo de 2 píxeles. Nunca suprimas el foco sin un reemplazo:

/* globals.css */
/* NEVER do this: */
:focus { outline: none; }

/* DO this: custom, visible focus ring */
:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
  border-radius: 4px;
}

/* Remove for mouse users (only show for keyboard) */
:focus:not(:focus-visible) {
  outline: none;
}

ARIA: Cuándo y cómo usarlo

Los atributos ARIA (Aplicaciones de Internet enriquecidas accesibles) modifican la forma en que las tecnologías de asistencia interpretan el DOM. La primera regla de ARIA: no lo use si existe un elemento HTML nativo para su caso de uso.

Etiquetas ARIA

// Icon-only button — screen reader has nothing to announce without aria-label
<button aria-label="Close dialog">
  <X className="h-4 w-4" aria-hidden="true" />
</button>

// Form field with visible label — use htmlFor, not aria-label
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Input with visible description
<input
  id="password"
  type="password"
  aria-describedby="password-requirements"
/>
<p id="password-requirements">Must be at least 12 characters.</p>

Regiones en vivo de ARIA

Anuncie cambios de contenido dinámico sin mover el foco:

// Status messages (search results count, form submission status)
function SearchResults({ count, loading }: Props) {
  return (
    <>
      {/* aria-live="polite" waits for user to finish current action */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {loading ? 'Loading results...' : `${count} results found`}
      </div>
      {/* Visual result count (not for screen readers — aria-hidden) */}
      <span aria-hidden="true">{count} results</span>
    </>
  );
}

// Error messages (aria-live="assertive" interrupts immediately)
function FormError({ error }: { error: string | null }) {
  return (
    <div
      role="alert"
      aria-live="assertive"
      className={cn('text-red-500 text-sm', !error && 'hidden')}
    >
      {error}
    </div>
  );
}

ARIA para widgets personalizados

Cuando deba crear un widget personalizado (panel de pestañas, vista de árbol, cuadro combinado), siga exactamente los patrones de la Guía de prácticas de creación (APG) de ARIA:

// Accessible tabs (ARIA tab pattern)
export function Tabs({ items }: { items: Tab[] }) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <div role="tablist" aria-label="Content tabs">
        {items.map((item, i) => (
          <button
            key={item.id}
            role="tab"
            aria-selected={active === i}
            aria-controls={`panel-${item.id}`}
            id={`tab-${item.id}`}
            tabIndex={active === i ? 0 : -1} // Roving tabindex
            onClick={() => setActive(i)}
            onKeyDown={(e) => {
              if (e.key === 'ArrowRight') setActive((active + 1) % items.length);
              if (e.key === 'ArrowLeft') setActive((active - 1 + items.length) % items.length);
            }}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, i) => (
        <div
          key={item.id}
          role="tabpanel"
          id={`panel-${item.id}`}
          aria-labelledby={`tab-${item.id}`}
          hidden={active !== i}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

Formularios y manejo de errores

Los formularios accesibles se encuentran entre las mejoras de mayor impacto para los usuarios con discapacidades cognitivas y motoras.

// Accessible form field with error state
function TextField({
  id,
  label,
  error,
  required,
  hint,
  ...props
}: TextFieldProps) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div>
      <label htmlFor={id} className="font-medium">
        {label}
        {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
        {required && <span className="sr-only">(required)</span>}
      </label>

      {hint && (
        <p id={hintId} className="text-sm text-gray-500 mt-1">
          {hint}
        </p>
      )}

      <input
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={describedBy}
        className={cn('input', error && 'border-red-500')}
        {...props}
      />

      {error && (
        <p id={errorId} className="text-sm text-red-500 mt-1" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

Imágenes y medios

// Informative image
<img src="/chart.png" alt="Bar chart showing 40% revenue growth in Q4 2025" />

// Decorative image — empty alt hides it from screen readers
<img src="/divider.png" alt="" role="presentation" />

// Complex image — use aria-describedby for long descriptions
<figure>
  <img
    src="/architecture.png"
    alt="System architecture diagram"
    aria-describedby="arch-desc"
  />
  <figcaption id="arch-desc">
    The diagram shows three tiers: frontend Next.js on port 3000,
    NestJS API on port 3001, and PostgreSQL database on port 5433.
    Redis sits between the API and database layers.
  </figcaption>
</figure>

// SVG icons used as decoration
<svg aria-hidden="true" focusable="false">
  <use href="#icon-search" />
</svg>

Pruebas automatizadas con axe-core

pnpm add -D @axe-core/playwright axe-core
// tests/a11y/homepage.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Homepage accessibility', () => {
  test('should have no WCAG 2.1 AA violations', async ({ page }) => {
    await page.goto('/');

    const results = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
      .exclude('#third-party-widget') // Exclude known external violations
      .analyze();

    expect(results.violations).toEqual([]);
  });

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/');
    // Tab through interactive elements and verify focus is visible
    await page.keyboard.press('Tab');
    const focusedElement = await page.evaluate(
      () => document.activeElement?.getAttribute('href')
    );
    expect(focusedElement).toBe('#main-content'); // Skip link
  });
});

Agregar a la canalización de CI:

# .github/workflows/ci.yml
- name: Run accessibility tests
  run: cd apps/web && npx playwright test tests/a11y --reporter=html
- uses: actions/upload-artifact@v4
  with:
    name: a11y-report
    path: apps/web/playwright-report/

Preguntas frecuentes

¿Cuál es la diferencia entre WCAG 2.1 A, AA y AAA?

El nivel A es el mínimo: fallar en el nivel A significa que el contenido es inaccesible para algunos usuarios de manera fundamental. El nivel AA es el estándar legal en la mayoría de las jurisdicciones y se dirige a las necesidades más amplias de los usuarios. El nivel AAA es una aspiración: algunos criterios no se pueden cumplir para todos los tipos de contenido. Apunte al cumplimiento de AA como punto de partida y apunte a AAA cuando sea práctico.

¿El uso de una biblioteca de componentes como shadcn/ui hace que mi aplicación sea accesible?

shadcn/ui se basa en las primitivas de la interfaz de usuario de Radix, a las que se puede acceder por diseño: incluyen funciones ARIA correctas, navegación con teclado y gestión de enfoque. Sin embargo, aún necesita agregar etiquetas significativas, manejar los estados de error de manera accesible, garantizar un contraste de color suficiente con su tema personalizado y realizar pruebas con tecnologías de asistencia reales. Las bibliotecas de componentes reducen la carga pero no eliminan la necesidad de realizar pruebas de accesibilidad.

¿Cómo realizo pruebas con un lector de pantalla?

En Windows, usa NVDA (gratis) con Firefox o Chrome. En macOS, utilice VoiceOver (integrado, Cmd+F5) con Safari. En dispositivos móviles, use TalkBack (Android) o VoiceOver (iOS). Pruebe los recorridos de los usuarios clave: finalización de formularios, interacciones modales, navegación a través de puntos de referencia y lectura de contenido dinámico. Las pruebas de lectores de pantalla detectan anuncios, orden de lectura y comportamiento de enfoque que las herramientas automatizadas pasan por alto.

¿Qué es el patrón de índice tabulador móvil?

El índice de tabulación móvil es el patrón de teclado para widgets compuestos (listas de pestañas, barras de herramientas, grupos de radio, vistas de árbol). Sólo un elemento del grupo tiene tabIndex={0} a la vez: el elemento activo. Todos los demás obtienen tabIndex={-1}. Las teclas de flecha mueven el foco dentro del grupo y actualizan qué elemento tiene tabIndex 0. Esto evita que el usuario navegue por cada elemento del grupo: ingresa al grupo con Tab, navega con las teclas de flecha y sale con Tab.

¿Cómo manejo la accesibilidad del contenido dinámico cargado a través de AJAX?

Utilice aria-live regiones para actualizaciones de estado (los resultados de la búsqueda cuentan, guardan confirmaciones). Para reemplazar secciones de página completa, mueva el foco al encabezado o contenedor del nuevo contenido después de cargarlo. Para los estados de carga, use aria-busy="true" en la región que se está actualizando y una región aria-live="polite" para anunciar la finalización. Pruebe siempre con un lector de pantalla para verificar que los anuncios sean claros y oportunos.


Próximos pasos

La accesibilidad web es una práctica continua, no una auditoría única. Comience arreglando su HTML semántico y el contraste de color, luego agregue capas de navegación con teclado y ARIA para widgets complejos, y automatice la validación WCAG en su proceso de CI para detectar regresiones.

ECOSIRE crea aplicaciones web compatibles con WCAG 2.1 AA como estándar básico en cada proyecto. Si necesita una auditoría de accesibilidad o quiere desarrollar el cumplimiento desde cero, explore nuestros servicios de ingeniería frontend.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp