Addaw, ir a inicio Donar

Lección 7 de 10 · 20 min de lectura · Revisado el 26/10/2026

SPAs (React, Vue, Angular): enrutado, foco, anuncios y títulos

Una aplicación de una sola página (SPA) cambia el contenido sin recargar el documento. Eso rompe las tres cosas que un navegador hacía solas en cada carga: poner el foco al principio, anunciar el título de la nueva página y empezar a leer desde arriba. Además, los frameworks facilitan construir interfaces enteras con div y manejadores de eventos, y sus abstracciones (componentes, plantillas, portales) añaden sus propias trampas. Esta lección recoge lo que hay que resolver en cualquier SPA y cómo se hace en React, Vue y Angular.

Lo que hay que resolver en cada cambio de ruta

  1. Título: actualizar document.title con el título de la vista (2.4.2).
  2. Foco: moverlo al inicio de la nueva vista, normalmente al <h1> con tabindex="-1" (2.4.3); así el lector anuncia el título y el siguiente Tab empieza en el contenido. Algunas guías prefieren enfocar el contenedor <main> o un enlace de salto; lo importante es que sea coherente y que la persona sepa que ha cambiado de página.
  3. Anuncio: opcionalmente, escribir «Página cargada: Mis expedientes» en una región role="status" global (4.1.3).
  4. Scroll: volver arriba, porque los enrutadores suelen mantener la posición anterior.
  5. Estado de carga: si la vista tarda, mostrar un «Cargando…» en la región de estado y aria-busy="true" en el contenedor hasta que lleguen los datos; al terminar, mover el foco.

Lo demás que vimos en los cursos anteriores (HTML semántico, formularios, teclado, ARIA, componentes) y en la lección de JavaScript de este curso aplica igual dentro de un componente de framework; el framework no lo hace por ti ni te lo impide.

Infografía · Proceso

Qué hacer en cada cambio de ruta de una SPA

Cinco pasos para recuperar lo que el navegador hacía solo al cargar una página

  1. Paso 1: Indicar la espera«Cargando…» en la región de estado y aria-busy en el contenedor
  2. Paso 2: Actualizar el títulodocument.title con el nombre de la vista y del sitio (2.4.2)
  3. Paso 3: Volver arribaScroll al inicio: los enrutadores conservan la posición anterior
  4. Paso 4: Mover el focoAl h1 con tabindex="-1": se anuncia y el siguiente Tab sigue en el contenido
  5. Paso 5: Anunciar el cambio«Página cargada: …» en una región role="status" permanente (4.1.3)
ADDAW · Cursos de accesibilidad
Leer la infografía como texto

Proceso de cinco pasos que una aplicación de una sola página debe ejecutar en cada cambio de ruta. Paso 1, indicar la espera: mostrar «Cargando…» en la región de estado y poner aria-busy en el contenedor. Paso 2, actualizar el título: document.title con el nombre de la vista y del sitio, según 2.4.2. Paso 3, volver arriba: hacer scroll al inicio, porque los enrutadores conservan la posición anterior. Paso 4, mover el foco: al h1 con tabindex -1, para que se anuncie y el siguiente Tab continúe en el contenido nuevo. Paso 5, anunciar el cambio: escribir «Página cargada» y el nombre de la vista en una región role status permanente, según 4.1.3.

React

React Router no gestiona el foco y, salvo que uses sus funciones de metadatos, tampoco el título. La solución habitual es un componente que se monta en cada ruta o un efecto que escucha los cambios de ubicación:

Ejemplo

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function Pagina({ titulo, children }) {
  const h1 = useRef(null);
  const { pathname } = useLocation();
  useEffect(() => {
    document.title = `${titulo} - Sede electrónica`;
    window.scrollTo(0, 0);
    h1.current?.focus();
  }, [pathname, titulo]);
  return (
    <main id="contenido">
      <h1 ref={h1} tabIndex={-1}>{titulo}</h1>
      {children}
    </main>
  );
}

En la primera carga de la aplicación no hace falta mover el foco, porque el navegador ya empieza por el principio del documento: si quieres respetarlo, omite el efecto en el primer renderizado y aplícalo solo en los cambios de ruta posteriores.

Particularidades de React que afectan a la accesibilidad:

  • El atributo es htmlFor en lugar de for y tabIndex en lugar de tabindex; los atributos aria-* y role se escriben igual que en HTML.
  • Los identificadores de componentes reutilizables deben generarse con useId() para evitar id duplicados que rompan label/for y aria-labelledby.
  • Los portales (createPortal) colocan los modales al final del body: el foco debe gestionarse explícitamente, y el resto de la aplicación debe hacerse inerte.
  • Las regiones activas deben estar montadas de forma permanente (en el layout), no renderizarse condicionalmente con el mensaje dentro.
  • Librerías con buena base de accesibilidad: React Aria (Adobe), Radix Primitives, Headless UI. Evita librerías de componentes que no documenten su soporte de teclado.

Vue

Vue Router expone guardas de navegación (router.afterEach) y el meta de cada ruta, que es un buen lugar para el título:

router.afterEach((to) => {
  document.title = `${to.meta.titulo} - Sede electrónica`;
  nextTick(() => {
    const h1 = document.querySelector('main h1');
    h1?.setAttribute('tabindex', '-1');
    h1?.focus();
    anunciar(`Página cargada: ${to.meta.titulo}`);
  });
});

Detalles propios de Vue: v-if elimina el elemento del DOM (equivale a display:none), mientras que v-show lo oculta con CSS (también fuera del árbol de accesibilidad); ninguno de los dos deja contenido fantasma, pero v-if destruye y recrea componentes, lo que puede hacer perder el foco. Las transiciones de <Transition> deben respetar prefers-reduced-motion. Para los id únicos, Vue 3.5 incorpora useId(); en versiones anteriores, genera un identificador por instancia. Librerías: Vuetify tiene un soporte razonable; Headless UI y Reka UI (antes Radix Vue) ofrecen primitivas accesibles.

Angular

Angular es el framework que más herramientas incorpora de serie a través del CDK (Component Dev Kit), módulo @angular/cdk/a11y:

  • LiveAnnouncer: servicio para escribir en una región activa global sin crearla a mano: this.announcer.announce('Página cargada: Mis expedientes', 'polite').
  • FocusTrap y la directiva cdkTrapFocus: ciclo del foco dentro de diálogos.
  • FocusMonitor: detecta si el foco llegó por teclado, ratón o programa, útil para indicadores de foco personalizados.
  • ListKeyManager y FocusKeyManager: implementan el roving tabindex y la navegación con flechas para listas, menús y pestañas.
  • InteractivityChecker: comprueba si un elemento es enfocable o tabulable.
this.router.events
  .pipe(filter((e) => e instanceof NavigationEnd))
  .subscribe(() => {
    this.title.setTitle(`${this.rutaActual.titulo} - Sede electrónica`);
    const h1 = document.querySelector('main h1') as HTMLElement | null;
    h1?.setAttribute('tabindex', '-1');
    h1?.focus();
  });

El servicio Title de @angular/platform-browser gestiona el título y, desde Angular 14, la propiedad title en la definición de cada ruta lo actualiza automáticamente. Angular Material usa el CDK internamente y sus componentes (diálogos, menús, pestañas) tienen una base de accesibilidad sólida, aunque siempre hay que verificar los nombres accesibles que tú aportas.

Error frecuente

Confiar en que «la librería de componentes es accesible». Una librería puede dar un diálogo con ciclo de foco perfecto, pero si tú no le pasas un título, el diálogo no tiene nombre; si tu pestaña contiene solo un icono, no tiene nombre; si renderizas el componente dentro de un <a>, anidas interactivos. La librería resuelve el patrón; el contenido y la integración son responsabilidad de quien la usa.

Problemas transversales de las SPA

  • Botones hechos con div: los frameworks permiten poner onClick en cualquier cosa, y los sistemas de diseño propios lo abusan. La regla 1 de ARIA sigue vigente.
  • Formularios controlados: la validación en tiempo real tecla a tecla, tan fácil de implementar con estado reactivo, es agresiva; valida al salir del campo o al enviar.
  • Carga perezosa y esqueletos: los skeletons de carga son contenido vacío para el lector; acompáñalos de aria-busy y un mensaje de estado.
  • Scroll infinito: sin paginación ni botón «Cargar más», el pie de página puede quedar inalcanzable y el lector no llega nunca al final. Ofrece siempre un botón.
  • Renderizado en servidor e hidratación: el HTML inicial debe ser ya correcto (landmarks, títulos), porque es lo que recibe el lector antes de que el JavaScript tome el control.

Punto clave

Un componente de layout que, en cada cambio de ruta, actualiza el título, enfoca el <h1>, hace scroll arriba y anuncia la carga, más una región de estado permanente y id únicos por instancia: con eso una SPA recupera lo que el navegador hacía solo. Todo lo demás es HTML, teclado y ARIA como en cualquier web.

Resumen

  • En cada cambio de ruta: document.title, foco al <h1> con tabindex="-1", scroll arriba y, si procede, anuncio en una región de estado.
  • React: useEffect con useLocation, useId para los id, portales con gestión de foco, regiones activas montadas siempre.
  • Vue: router.afterEach con nextTick, meta.titulo, cuidado con v-if y la pérdida del foco.
  • Angular: CDK a11y (LiveAnnouncer, FocusTrap, FocusKeyManager) y la propiedad title de las rutas.
  • La librería resuelve el patrón, pero nombres, contenido e integración siguen siendo tu responsabilidad.

Comprueba lo que has aprendido

Esta lección se puede leer libremente. Para hacer su test de 3 preguntas, los ejercicios prácticos con páginas reales, guardar tu avance y optar al examen y al certificado, matricúlate en el curso.