En 07-01 aprendiste a anunciar cambios dentro de una página. Ahora subimos un nivel: ¿qué pasa cuando toda la aplicación es una sola página y la "navegación" no recarga nada? El panel del alumno de Cursalia —"Mis cursos", "Progreso", "Certificados", "Ajustes"— es una SPA (Single Page Application): construida con un framework de cliente (React, Vue o Angular, da igual cuál a este nivel), carga un único HTML y, al "cambiar de página", el enrutador de cliente reescribe el DOM sin pedir una página nueva al servidor. Para Sofía o Hugo, que ven la pantalla, la transición es fluida y rápida. Para Marta, con NVDA, es un desastre silencioso: el lector de pantalla no anuncia la nueva página y el foco se queda flotando donde estaba. En esta lección resolvemos ese problema central y sus derivados. No nos casaremos con ningún framework: los principios son los mismos en todos.

Contenido

  1. Por qué una SPA rompe la accesibilidad que el navegador daba gratis
  2. Las tres reacciones a un cambio de ruta: título, foco y anuncio
  3. Actualizar el document.title
  4. Mover el foco al montar la vista
  5. Anunciar la ruta con un "route announcer" (live region de router)
  6. Restaurar el foco al volver atrás y no romper el botón Atrás
  7. Skip links y modales enrutados en una SPA
  8. Carga diferida y estados de vista
  9. Errores comunes y consejos
  10. Ejercicios
  11. Conclusión

  1. Por qué una SPA rompe la accesibilidad que el navegador daba gratis

En una web tradicional (multipágina), cada clic en un enlace provoca una recarga completa. Esa recarga, que parece un detalle técnico, le regalaba al usuario de lector de pantalla tres cosas de forma automática:

Al recargar una página, el navegador… …y el usuario de lector de pantalla obtenía
Cambia el <title> NVDA anuncia el título de la nueva página
Reinicia el foco al principio del documento El foco empieza en un lugar conocido, arriba
Reconstruye el árbol de accesibilidad entero El lector "sabe" que está en una página nueva

Una SPA elimina la recarga, y con ella se pierden esas tres cortesías. El enrutador de cliente cambia la URL con la History API (pushState), intercambia unos nodos del DOM… y ya está. Para Marta, no ha pasado nada: NVDA no anuncia el cambio, el foco sigue en el enlace que pulsó (que puede haber desaparecido del DOM), y no hay ninguna señal de que ahora esté en "Certificados" en lugar de en "Mis cursos". Nuestro trabajo es devolver a mano lo que la recarga daba gratis.

flowchart TB
  A[Usuario pulsa un enlace del panel] --> B{¿Web tradicional<br/>o SPA?}
  B -->|Tradicional| C[Recarga: título, foco y<br/>árbol se renuevan solos]
  B -->|SPA| D[pushState: el DOM cambia<br/>pero nada se anuncia]
  D --> E[Debemos, por código:<br/>1 título · 2 foco · 3 anuncio]

  1. Las tres reacciones a un cambio de ruta: título, foco y anuncio

Cada vez que el enrutador de la SPA completa un cambio de ruta, debemos disparar hasta tres reacciones. No siempre las tres a la vez, pero son el repertorio:

  1. Actualizar el document.title — para que el título de la pestaña y el anuncio del lector reflejen la vista actual.
  2. Mover el foco — a un lugar predecible de la nueva vista (el <h1> con tabindex="-1", o una región), para que el teclado no se quede perdido.
  3. Anunciar el cambio — con una región en vivo de router, por si no movemos el foco o queremos un mensaje explícito de "página cargada".

Hay dos estrategias combinables: mover el foco al encabezado (que de paso hace que el lector lea la nueva vista) o anunciar con una live region sin mover el foco. Muchos equipos hacen ambas o eligen según el caso. Veámoslas una a una.

  1. Actualizar el document.title

Es lo más fácil y lo primero que debes arreglar. En una recarga, el <title> cambia solo; en una SPA, lo cambias tú al resolver la ruta:

// Al entrar en cada ruta del panel del alumno
function alEntrarEnRuta(ruta) {
  document.title = `${ruta.titulo} · Panel del alumno · Cursalia`;
}
// Ej.: "Progreso · Panel del alumno · Cursalia"

Por qué importa: muchos lectores de pantalla anuncian el document.title cuando cambia, y es lo que el usuario oye al orientarse. Además mejora el historial del navegador, los marcadores y el SEO. Pon el nombre de la vista primero: "Progreso · Cursalia" es más útil que "Cursalia · Progreso" cuando el usuario tiene 20 pestañas abiertas.

  1. Mover el foco al montar la vista

La técnica más recomendada al cambiar de ruta es mover el foco al encabezado principal de la nueva vista. Le das al <h1> un tabindex="-1" (enfocable por código, fuera del tabulado natural) y lo enfocas cuando la vista termina de montarse:

<!-- Cada vista del panel arranca con su h1 preparado para recibir foco -->
<main id="vista">
  <h1 tabindex="-1" id="titulo-vista">Progreso</h1>
  <!-- … contenido de la vista … -->
</main>
// Se llama DESPUÉS de que el framework haya pintado la nueva vista
function enfocarNuevaVista() {
  const h1 = document.getElementById('titulo-vista');
  h1.focus();          // NVDA lee "Progreso, encabezado nivel 1"
  // Opcional: llevar el scroll arriba, como haría una recarga
  window.scrollTo(0, 0);
}

Al enfocar el <h1>, ocurren dos cosas buenas a la vez: Marta oye el nombre de la nueva vista (porque el lector anuncia el elemento enfocado) y Lucía tiene el foco de teclado en un punto lógico desde el que empezar a tabular. Es la solución 2-en-1.

Importante — momento correcto: enfoca después de que el framework haya renderizado la vista. En React sería dentro de un useEffect que depende de la ruta; en Vue, en el hook de montado; en Angular, tras la navegación. Si enfocas antes de que el <h1> exista, no pasa nada. Por eso este patrón vive siempre en el ciclo de vida "vista ya montada".

  1. Anunciar la ruta con un "route announcer" (live region de router)

La alternativa (o complemento) a mover el foco es un route announcer: una única región en vivo, presente siempre en el layout, donde escribimos el nombre de la nueva vista en cada navegación. Reutiliza todo lo de 07-01:

<!-- En el layout raíz de la SPA, SIEMPRE presente, visualmente oculto -->
<div id="route-announcer" role="status" aria-live="polite" class="sr-only"></div>
/* Oculto a la vista, pero disponible para el lector de pantalla */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
// Enganchado al evento "navegación completada" del router
function anunciarRuta(titulo) {
  document.getElementById('route-announcer').textContent =
    `${titulo}, página cargada`;
}

Fíjate en que la región está siempre en el DOM desde el arranque (no la creamos en cada navegación): es exactamente la regla de oro de 07-01. Cambiamos solo su textContent, y como es role="status" (polite), NVDA anuncia "Progreso, página cargada" sin interrumpir.

¿Foco al <h1> o route announcer? Guía práctica:

Situación Recomendación
Navegación normal entre vistas del panel Mover el foco al <h1> (2-en-1)
Quieres un mensaje explícito "página cargada" sin robar el foco Route announcer
Vista muy compleja donde el foco al h1 desorienta Route announcer + no mover foco
Máxima robustez Ambas: foco al h1 y título actualizado

  1. Restaurar el foco al volver atrás y no romper el botón Atrás

El botón Atrás del navegador es sagrado. Una SPA bien hecha usa la History API de modo que Atrás/Adelante funcionen como el usuario espera. Dos reglas:

  • No rompas el botón Atrás. Si interceptas la navegación, usa history.pushState/popstate correctamente para que Atrás vuelva a la vista anterior, no fuera de la aplicación. Un botón Atrás que "se salta" pasos o saca al usuario de Cursalia es desorientador para todos, y crítico para quien depende del teclado.
  • Gestiona el foco al volver. Cuando el usuario pulsa Atrás y regresa, por ejemplo, del detalle de un curso a la lista, lo ideal es devolver el foco al elemento desde el que salió (la tarjeta del curso que abrió), no al principio. Esto requiere recordar la posición:
// Al navegar HACIA un detalle, recordamos quién nos trajo
let origenNavegacion = null;
enlaceCurso.addEventListener('click', () => {
  origenNavegacion = enlaceCurso; // guardamos el disparador
});

// Al volver (popstate) a la lista, restauramos el foco si podemos
window.addEventListener('popstate', () => {
  // tras renderizar la vista de destino:
  if (origenNavegacion && document.contains(origenNavegacion)) {
    origenNavegacion.focus();
  } else {
    document.getElementById('titulo-vista')?.focus(); // plan B: el h1
  }
});

Es el mismo principio del "devolver el foco al disparador" que aprendiste con los modales en el Módulo 5, ahora aplicado a la navegación entera.

  1. Skip links y modales enrutados en una SPA

Skip links que siguen funcionando

El enlace "Saltar al contenido principal" (Módulo 2) apunta a #contenido-principal. En una SPA, como el <main> se reconstruye en cada vista, asegúrate de que el ancla de destino sigue existiendo tras cada navegación y de que el skip link mueve el foco de verdad (no solo el scroll):

<a href="#contenido-principal" class="skip-link">Saltar al contenido principal</a>
<!-- … -->
<main id="contenido-principal" tabindex="-1">…</main>

El tabindex="-1" en el <main> garantiza que, al activar el skip link, el foco aterrice ahí (algunos navegadores no enfocan destinos sin tabindex). Si tu framework recrea el <main>, comprueba que conserva ese id y ese tabindex.

Modales enrutados

Un patrón habitual en SPAs es que un modal tenga su propia ruta (por ejemplo /panel/curso/42/matricula abre el modal de matrícula sobre la lista). Combina lo del Módulo 5 con lo de este módulo:

  • Al abrir el modal (entrar en su ruta): mueve el foco dentro del diálogo y activa el focus trap (Módulo 5).
  • Al cerrarlo (salir de la ruta, incluido con el botón Atrás): devuelve el foco al elemento que lo abrió.
  • Cuida que el botón Atrás cierre el modal en lugar de sacar al usuario de la vista: si el modal es una ruta, popstate debe cerrarlo, no navegar fuera.

  1. Carga diferida y estados de vista

Las SPAs cargan trozos de código y datos bajo demanda (lazy loading, code splitting). Mientras una vista carga, hay un lapso en el que no hay contenido. Aplica lo aprendido en 07-01:

  • Marca la región que se está poblando con aria-busy="true" y ponla a false al terminar.
  • Muestra un estado de carga anunciado ("Cargando tu progreso…") en una región status, no un spinner mudo.
  • Cuando lleguen los datos, decide si mueves el foco al <h1> de la vista ya poblada (evita enfocar contenido que aún es un esqueleto).
async function montarVistaProgreso() {
  const main = document.getElementById('contenido-principal');
  main.setAttribute('aria-busy', 'true');
  document.getElementById('route-announcer').textContent = 'Cargando progreso…';
  const datos = await api.progreso();      // llega asíncrono
  pintarProgreso(datos);
  main.setAttribute('aria-busy', 'false');
  document.getElementById('titulo-vista').focus(); // ahora sí, foco a la vista lista
}

Errores Comunes y Consejos

  • No cambiar el document.title al navegar. El usuario de lector de pantalla no tiene forma de saber en qué vista está. Es el arreglo más barato y de mayor impacto.
  • Dejar el foco en el enlace que ya no existe. Tras la navegación, el nodo del enlace puede haber sido eliminado del DOM; el foco cae al <body> y Marta se pierde. Mueve el foco al <h1> de la nueva vista.
  • Crear la route announcer en cada navegación. Igual que en 07-01: la región debe estar en el layout desde el arranque, presente y vacía. Solo cambias su texto.
  • Enfocar antes de que la vista esté montada. Enfocar un <h1> que aún no existe no hace nada. Hazlo en el ciclo de vida "ya renderizado" del framework.
  • Romper el botón Atrás. Si Atrás saca al usuario de la SPA o salta pasos, has roto una expectativa universal. Usa la History API con cuidado.
  • Poner aria-live en el contenedor de la vista entera. Si marcas todo el <main> como live region, cada cambio de vista intenta leer toda la página. Anuncia solo un mensaje breve en una región dedicada.
  • Consejo: encapsula "cambiar título + mover foco + anunciar" en una sola función que tu router llame en cada navegación. Así es imposible olvidar un paso, y toda la SPA se comporta igual.

Ejercicios

Ejercicio 1. Marta navega en el panel del alumno de "Mis cursos" a "Certificados". Visualmente todo cambia, pero ella no percibe nada: NVDA sigue callado y su foco está en el enlace que pulsó. Enumera las tres reacciones que debería disparar el router al completar el cambio de ruta y explica qué resuelve cada una.

Ejercicio 2. Un compañero implementa el anuncio de ruta así. ¿Por qué Marta no oye nada y cómo lo arreglas?

function anunciar(titulo) {
  const div = document.createElement('div');
  div.setAttribute('role', 'status');
  div.textContent = `${titulo}, página cargada`;
  document.body.appendChild(div);
}

Ejercicio 3. En la SPA, el modal de matrícula tiene su propia ruta. Describe qué debe pasar con el foco al abrirlo y al cerrarlo, y qué debe hacer el botón Atrás del navegador estando el modal abierto.

Soluciones

Solución 1. (1) Actualizar document.title a algo como "Certificados · Panel del alumno · Cursalia": da al lector y a la pestaña la identidad de la nueva vista. (2) Mover el foco al <h1 tabindex="-1">Certificados</h1> de la vista recién montada: hace que NVDA lea el nombre de la vista y sitúa el foco de teclado en un punto lógico. (3) Anunciar el cambio en la route announcer (role="status") por si no se mueve el foco o se quiere un "página cargada" explícito. Con las tres, se reconstruye lo que una recarga daba gratis: identidad, foco y aviso.

Solución 2. No se oye nada porque la región role="status" se crea y se rellena en el mismo instante (y se inserta en el DOM ya con texto). El lector no la tenía registrada como región en vivo. La solución es tener una única región en el layout, presente desde el arranque y vacía, y limitarse a cambiar su texto:

<div id="route-announcer" role="status" class="sr-only"></div>
function anunciar(titulo) {
  document.getElementById('route-announcer').textContent = `${titulo}, página cargada`;
}

Solución 3. Al abrir el modal (entrar en su ruta): mover el foco dentro del diálogo (al título o al primer campo) y activar el focus trap del Módulo 5. Al cerrar (salir de la ruta): devolver el foco al elemento que lo abrió, guardado como disparador. El botón Atrás, con el modal abierto, debe cerrar el modal (equivalente a salir de su ruta con popstate), no navegar fuera de la vista subyacente ni sacar al usuario de Cursalia. Así el modal se comporta de forma predecible tanto con el ratón como con el teclado y el historial.

Conclusión

Una SPA elimina la recarga de página y, con ella, tres cortesías que el navegador daba gratis a los lectores de pantalla: título nuevo, foco reiniciado y árbol reconstruido. Ahora sabes devolverlas a mano en cada cambio de ruta —actualizar el document.title, mover el foco al <h1> y/o anunciar con un route announcer— además de restaurar el foco al volver atrás, no romper el botón Atrás, mantener los skip links y los modales enrutados, y gestionar la carga diferida. El hilo con 07-01 es directo: las mismas live regions y la misma gestión de foco, ahora a escala de aplicación entera.

Con el panel del alumno ya accesible como SPA, queda un frente que hemos ido rozando desde el principio: Cursalia no está en un solo idioma, sino en es/ca/en, y el multilingüismo tiene sus propias trampas de accesibilidad. ¿Sabe el lector de pantalla en qué idioma leer cada texto? ¿Qué pasa con la dirección del texto, las fechas, los subtítulos por idioma o los alt traducidos? Eso es lo que abordamos en la siguiente lección, 07-03: Internacionalización y Localización.

© Copyright 2026. Todos los derechos reservados