El Módulo 6 cerró con un hallazgo que nos dejó una espina clavada: el P1 de Marta. Al confirmar su matrícula, Cursalia mostraba un bonito mensaje verde de "¡Matrícula realizada con éxito!"… que Marta, con NVDA, nunca oía. El mensaje aparecía en pantalla sin recargar la página, y un lector de pantalla no "mira" la pantalla: solo conoce lo que el navegador le comunica a través del árbol de accesibilidad. Si insertas contenido en el DOM sin más, quien no lo ve, no se entera. En esta lección resolvemos ese problema —y toda su familia— con dos herramientas: las regiones en vivo (aria-live) para anunciar cambios, y la gestión de foco para llevar al usuario a donde ocurre la acción. Con esto damos el salto de las páginas estáticas al contenido que cambia sin recargar, que es donde vive la web moderna.

Contenido

  1. El problema: el DOM cambia y nadie se entera
  2. Regiones en vivo (aria-live) en profundidad
  3. polite frente a assertive: la cortesía del anuncio
  4. Roles implícitos: status, alert y log
  5. Afinar el anuncio: aria-atomic y aria-relevant
  6. Aplicado a Cursalia: resultados de filtro, cuestionario, toasts y matrícula
  7. Gestión de foco en cambios dinámicos
  8. Contenido asíncrono: "cargar más", aria-busy y estados de carga
  9. Patrones difíciles: tooltips, disclosure diferido y drag-and-drop
  10. Errores comunes y consejos
  11. Ejercicios
  12. Conclusión

  1. El problema: el DOM cambia y nadie se entera

Un usuario que ve la pantalla detecta cualquier cambio visual de un vistazo: aparece un toast, cambia el número de resultados, se colorea de rojo un error. Pero las tecnologías de asistencia solo anuncian, por defecto, aquello a lo que el usuario navega o donde tiene el foco. Un cambio que ocurre lejos del foco —en otra zona de la página— pasa totalmente inadvertido para Marta (NVDA) o para Sofía, que mira con la lupa a 300% otra parte de la pantalla.

Hay dos formas de comunicar un cambio dinámico a las tecnologías de asistencia, y hay que elegir bien cuál:

Técnica Qué hace Cuándo usarla
Región en vivo Anuncia el cambio sin mover el foco El usuario sigue en su tarea; solo hay que informarle (resultados, toasts, estado)
Gestión de foco Lleva el foco al contenido nuevo El cambio exige atención o interacción inmediata (abrir modal, saltar a un error)

La regla mental: ¿el usuario necesita ir a algún sitio, o solo necesita enterarse? Si solo necesita enterarse, usa una región en vivo. Si necesita actuar sobre lo nuevo, mueve el foco. Confundir las dos es el origen de la mitad de los problemas de contenido dinámico.

  1. Regiones en vivo (aria-live) en profundidad

Una región en vivo es un elemento del DOM marcado para que, cuando su contenido cambie, la tecnología de asistencia lo anuncie automáticamente aunque el foco esté en otro sitio. En los Módulos 4 y 5 la mencionamos de pasada; aquí la abrimos en canal. Se declara con el atributo aria-live:

<!-- El contenedor existe desde el principio, vacío. -->
<div id="anuncio-filtro" aria-live="polite"></div>

Cuando JavaScript escriba dentro de ese div, NVDA lo leerá. El mecanismo es sencillo pero tiene una regla de oro que casi todo el mundo incumple:

La región en vivo debe existir en el DOM antes de que cambie su contenido. El lector de pantalla "vigila" las regiones que ya conoce. Si creas el <div aria-live> y le metes texto en el mismo instante, muchos lectores no llegan a registrarlo a tiempo y no anuncian nada.

Es decir: primero pintas el contenedor vacío (al cargar la página o la vista), y después, en un segundo momento, escribes dentro. Este matiz —región creada tarde— es el error número uno con aria-live, y lo veremos de nuevo en la sección 10.

  1. polite frente a assertive: la cortesía del anuncio

aria-live admite dos valores útiles (off es el tercero y significa "no anunciar"):

Valor Comportamiento Úsalo para
polite Espera a que el lector termine lo que está diciendo, y entonces anuncia La inmensa mayoría de casos: resultados, estados, confirmaciones
assertive Interrumpe de inmediato lo que el lector esté diciendo Solo emergencias: errores críticos, avisos de tiempo agotándose, pérdida de datos

assertive es como gritar en una conversación: a veces necesario, casi siempre grosero. Si abusas de él, interrumpes constantemente a Marta y le impides oír lo que estaba leyendo. Por defecto, polite. Reserva assertive para lo que de verdad no puede esperar.

<!-- Bien: un resultado de búsqueda no es una emergencia -->
<div aria-live="polite" id="resultados-count"></div>

<!-- Bien: un error de sesión a punto de expirar SÍ interrumpe -->
<div aria-live="assertive" id="aviso-sesion"></div>

<!-- Mal: interrumpir a Marta cada vez que cambia un contador trivial -->
<div aria-live="assertive" id="contador-visitas"></div>

  1. Roles implícitos: status, alert y log

En lugar de aria-live "a mano", a menudo es mejor usar un rol que ya lleva el comportamiento de región en vivo incorporado. Son más semánticos y más robustos entre navegadores y lectores:

Rol Equivale a Uso típico en Cursalia
role="status" aria-live="polite" + aria-atomic="true" "3 cursos encontrados", "Guardado", "Cargando…"
role="alert" aria-live="assertive" + aria-atomic="true" Error de formulario de matrícula, sesión caducada
role="log" aria-live="polite" (nuevos mensajes al final) Chat del foro, registro de actividad del panel
<!-- Contador de resultados del catálogo: status (polite) -->
<p role="status" id="contador-resultados"></p>

<!-- Error crítico de matrícula: alert (assertive) -->
<p role="alert" id="error-matricula"></p>

La ventaja de role="status" y role="alert" es que ya traen aria-atomic="true" y una semántica clara. role="log" es diferente: está pensado para una secuencia de mensajes que se van acumulando (el chat del foro de Cursalia), donde solo importa lo nuevo que llega al final, no releer todo el historial.

  1. Afinar el anuncio: aria-atomic y aria-relevant

Dos atributos permiten controlar cuánto y qué anuncia la región cuando cambia:

  • aria-atomic: si es true, el lector anuncia toda la región completa cada vez que algo cambia dentro. Si es false (por defecto en aria-live), anuncia solo el nodo que cambió.
<!-- aria-atomic="true": queremos que se lea la frase entera y con sentido -->
<p aria-live="polite" aria-atomic="true" id="estado-guardado">
  Progreso guardado a las <span id="hora">14:32</span>
</p>

Si al actualizar solo la <span> de la hora tuviéramos aria-atomic="false", Marta oiría un escueto "14:35" sin contexto. Con aria-atomic="true" oye la frase completa: "Progreso guardado a las 14:35". Por eso status y alert lo activan por defecto: casi siempre quieres el mensaje con su contexto.

  • aria-relevant: indica qué tipos de cambio disparan un anuncio (additions, removals, text, all). Su valor por defecto (additions text) sirve para la mayoría de casos: anuncia lo que se añade y los cambios de texto, pero no lo que se elimina. Rara vez necesitarás tocarlo; si un toast desaparece, normalmente no quieres anunciar su desaparición.

Consejo: en la práctica, role="status" y role="alert" con contenedores preexistentes cubren el 90% de tus necesidades. Reserva aria-atomic/aria-relevant para casos concretos donde el anuncio por defecto no diga lo que quieres.

  1. Aplicado a Cursalia: resultados de filtro, cuestionario, toasts y matrícula

Número de resultados al filtrar el catálogo

Cuando Marta aplica el filtro que construimos en el Módulo 5, la lista de cursos se actualiza sin recargar. Un usuario vidente ve que la rejilla cambia; Marta necesita oír cuántos resultados hay:

<!-- Presente desde la carga de la página, vacío -->
<p role="status" id="contador-resultados"></p>
function aplicarFiltro(cursos) {
  pintarRejilla(cursos);
  // Escribimos DESPUÉS, en un contenedor que ya existía:
  document.getElementById('contador-resultados').textContent =
    `${cursos.length} cursos encontrados`;
}

Ahora, al filtrar, NVDA anuncia "12 cursos encontrados" sin que Marta pierda su posición en la página.

Retroalimentación de un cuestionario

Al responder una pregunta del cuestionario, la corrección ("Correcto" / "Incorrecto, la respuesta era…") aparece dinámicamente. Como es información importante pero no una emergencia, role="status" (polite) encaja:

<p role="status" id="feedback-pregunta"></p>
function corregir(esCorrecta, explicacion) {
  const fb = document.getElementById('feedback-pregunta');
  fb.textContent = esCorrecta
    ? 'Correcto. ¡Bien hecho!'
    : `Incorrecto. ${explicacion}`;
}

Notificaciones / toasts

Los toasts son el caso clásico de región en vivo. Nunca les muevas el foco (interrumpiría la tarea del usuario): solo anúncialos. Un toast informativo va en una región polite; uno de error, en assertive:

<!-- Dos regiones fijas, una para cada urgencia -->
<div role="status" id="toast-info"></div>
<div role="alert"  id="toast-error"></div>
function mostrarToast(mensaje, tipo = 'info') {
  const region = tipo === 'error'
    ? document.getElementById('toast-error')
    : document.getElementById('toast-info');
  region.textContent = mensaje; // se anuncia solo
  // (la desaparición visual del toast tras 5 s no necesita anunciarse)
}

El mensaje de éxito de matrícula: el P1 de Marta, resuelto

Y aquí cerramos la espina del Módulo 6. El problema era que el mensaje de éxito se insertaba en el DOM sin ninguna región en vivo. La solución es exactamente la misma técnica:

<!-- Región presente en la página del flujo de matrícula, vacía de inicio -->
<div role="status" id="resultado-matricula"></div>
async function confirmarMatricula(cursoId) {
  await api.matricular(cursoId);
  // El mensaje ahora SÍ se anuncia a Marta:
  document.getElementById('resultado-matricula').textContent =
    '¡Matrícula realizada con éxito! Ya tienes acceso al curso.';
}

Con tres líneas, Marta pasa de no saber si su matrícula funcionó a oír la confirmación. Ese es el poder —y la sencillez— de una región en vivo bien puesta.

  1. Gestión de foco en cambios dinámicos

Las regiones en vivo anuncian sin mover el foco. Pero a veces el usuario necesita ir al contenido nuevo. La pregunta clave: ¿dónde mando el foco cuando inserto o elimino contenido?

  • Al insertar contenido con el que hay que interactuar (abrir un modal, expandir un formulario, mostrar un panel de errores para corregir): mueve el foco al nuevo contenido. En un panel de errores de validación, lleva el foco al resumen de errores o al primer campo inválido.
  • Al eliminar el elemento que tenía el foco (borrar una fila de "mis cursos", cerrar una tarjeta): si dejas que el foco caiga al <body>, el usuario de teclado se pierde. Mueve el foco a un lugar predecible y relacionado: el elemento siguiente de la lista, el contenedor padre, o el botón que originó la acción.
// Borrar un curso de "mis cursos" y no perder el foco
function eliminarCurso(fila) {
  const siguiente = fila.nextElementSibling || fila.previousElementSibling;
  fila.remove();
  // Enviar el foco a un lugar con sentido, nunca dejarlo huérfano
  (siguiente ?? document.getElementById('lista-mis-cursos')).focus();
}

Recuerda del Módulo 5 que, para poder enfocar por programa un contenedor que no es focusable de forma nativa (un <div>, un <h2>, una <ul>), necesita tabindex="-1": enfocable por código pero fuera del orden de tabulación natural.

  1. Contenido asíncrono: "cargar más", aria-busy y estados de carga

El contenido que llega por red (fetch) plantea dos retos: avisar de que se está cargando y gestionar el foco cuando llega.

Estados de carga con aria-busy

Mientras una región se está actualizando, aria-busy="true" le dice a la tecnología de asistencia "esto todavía no está listo, espera". Al terminar, lo pones a false:

async function cargarProgreso() {
  const panel = document.getElementById('panel-progreso');
  panel.setAttribute('aria-busy', 'true');
  const datos = await api.progreso();
  pintarProgreso(datos);
  panel.setAttribute('aria-busy', 'false'); // ya está listo
}

Combínalo con un mensaje "Cargando…" en una región status para que el usuario oiga el estado, no un silencio incómodo:

<div id="panel-progreso" aria-busy="false">
  <p role="status" id="estado-carga"></p>
  <!-- contenido -->
</div>

El botón "Cargar más"

Cuando el catálogo de Cursalia carga más cursos al pulsar "Cargar más", el usuario de teclado tiene un problema sutil: pulsa el botón, se añaden 12 cursos encima del botón… ¿y ahora dónde está su foco? Buenas prácticas:

  1. Anuncia cuántos elementos nuevos se han añadido (región status): "12 cursos más cargados".
  2. Mueve el foco al primero de los elementos nuevos (con tabindex="-1"), para que el usuario continúe justo donde se añadió el contenido, no de vuelta al principio.
async function cargarMas() {
  const nuevos = await api.masCursos();
  const primerNuevo = pintarYDevolverPrimero(nuevos); // devuelve el nodo, con tabindex="-1"
  document.getElementById('estado-carga').textContent =
    `${nuevos.length} cursos más cargados`;
  primerNuevo.focus(); // continuidad para el teclado
}

  1. Patrones difíciles: tooltips, disclosure diferido y drag-and-drop

Tooltips

Un tooltip accesible se asocia al control con aria-describedby y debe poder mostrarse tanto al foco como al hover (WCAG 1.4.13, Content on Hover or Focus: descartable con Esc, apuntable y persistente). Nunca pongas información esencial solo en un tooltip: es contenido secundario por definición.

<button aria-describedby="tip-guardar">Guardar borrador</button>
<div role="tooltip" id="tip-guardar" hidden>
  Se guarda automáticamente cada 30 segundos.
</div>

Con aria-describedby, cuando Marta enfoca el botón, NVDA lee "Guardar borrador, botón" y a continuación la descripción. No necesitas aria-live aquí: la asociación por describedby ya hace que se lea al enfocar.

Disclosure de contenido diferido (lazy)

A veces el contenido de un disclosure (del Módulo 5) no existe hasta que se abre: se pide por red al expandir. El reto es que la región en vivo y el foco funcionen después de que llegue el contenido, no antes:

async function abrirSeccionDiferida(btn, panel) {
  btn.setAttribute('aria-expanded', 'true');
  panel.hidden = false;
  panel.setAttribute('aria-busy', 'true');
  panel.innerHTML = '<p role="status">Cargando contenido…</p>';
  const html = await api.seccion(panel.dataset.id);
  panel.innerHTML = html;          // ahora sí existe el contenido
  panel.setAttribute('aria-busy', 'false');
}

Una nota sobre drag-and-drop accesible

El arrastrar y soltar con ratón es, por naturaleza, inaccesible para quien usa solo teclado (Lucía) o un lector de pantalla (Marta). WCAG 2.2 añadió el criterio 2.5.7 (Dragging Movements, AA), que exige que toda funcionalidad de arrastre tenga una alternativa que no requiera arrastrar. Para reordenar los cursos favoritos de Cursalia, no basta con el drag-and-drop: hay que ofrecer botones "Subir / Bajar" operables por teclado y anunciar el cambio de posición en una región en vivo ("Curso movido a la posición 2 de 5"). El arrastre es un extra visual, no la única vía. Esto enlaza directamente con lo que vimos en el Módulo 5 sobre operar todo por teclado.

Errores Comunes y Consejos

  • Crear la región en vivo y llenarla en el mismo instante. El lector no llega a registrarla y no anuncia nada. Pinta el contenedor vacío primero (al cargar la vista) y escribe dentro después.
  • Abusar de assertive. Interrumpe constantemente. Por defecto polite/status; assertive/alert solo para emergencias reales.
  • Insertar demasiado en la región en vivo. Si vuelcas un bloque HTML enorme, el lector intenta leerlo entero y satura. La región debe contener un mensaje breve, no medio catálogo.
  • Anuncios que se pisan. Dos regiones assertive disparándose a la vez, o actualizar la misma región tres veces en 200 ms: el usuario oye un batiburrillo. Agrupa el mensaje y escribe una sola vez.
  • Mover el foco cuando solo hacía falta anunciar (y viceversa). Un toast que roba el foco interrumpe la tarea; un modal que solo se "anuncia" pero no recibe el foco deja al teclado fuera. Elige según la tabla de la sección 1.
  • Dejar el foco huérfano al eliminar contenido. Si borras el elemento enfocado, reubica el foco en un vecino con sentido.
  • Consejo: ten en la página una sola región status y una sola alert reutilizables, presentes desde el arranque. Es más fiable que crear regiones nuevas al vuelo.

Ejercicios

Ejercicio 1. Este código intenta anunciar el número de resultados del filtro, pero Marta no oye nada. Identifica el error y corrígelo.

function filtrar(cursos) {
  const region = document.createElement('div');
  region.setAttribute('aria-live', 'polite');
  region.textContent = `${cursos.length} resultados`;
  document.body.appendChild(region);
}

Ejercicio 2. Clasifica cada mensaje de Cursalia como role="status" (polite) o role="alert" (assertive) y justifica: (a) "Progreso guardado", (b) "Tu sesión expirará en 60 segundos", (c) "3 cursos encontrados", (d) "Error: no se pudo procesar el pago".

Ejercicio 3. Al pulsar "Cargar más" en el catálogo, se añaden 12 cursos y el foco de Lucía se queda en el botón, que ahora ha bajado mucho en la página. Describe los dos ajustes de accesibilidad que aplicarías y por qué.

Soluciones

Solución 1. El error es que la región se crea y se rellena en el mismo momento (y se añade al DOM ya con texto dentro). El lector de pantalla no la tenía registrada como región en vivo, así que no anuncia el cambio. La solución es tener el contenedor aria-live/role="status" presente y vacío desde antes, y solo escribir su textContent en el momento del cambio:

<p role="status" id="resultados"></p>
function filtrar(cursos) {
  document.getElementById('resultados').textContent = `${cursos.length} resultados`;
}

Solución 2. (a) status: guardar es una confirmación rutinaria, no urge. (b) alert: la sesión a punto de expirar es tiempo sensible y puede provocar pérdida de datos; interrumpir está justificado. (c) status: un contador de resultados es información pasiva, nunca debe interrumpir. (d) alert: un error de pago bloquea una tarea crítica y el usuario debe saberlo de inmediato.

Solución 3. Primero, anunciar el cambio con una región role="status": "12 cursos más cargados", para que Marta sepa que la acción tuvo efecto. Segundo, mover el foco al primer curso recién añadido (dándole tabindex="-1" para poder enfocarlo por programa), de modo que Lucía continúe navegando desde el contenido nuevo en lugar de quedarse en un botón que ahora está descolgado. Así combinamos anuncio (para el lector) y foco (para el teclado), cada uno resolviendo su parte.

Conclusión

Has aprendido a hacer accesible el contenido que cambia sin recargar: regiones en vivo para anunciar (polite/assertive, status/alert/log, aria-atomic/aria-relevant) y gestión de foco para dirigir al usuario cuando hace falta interactuar. Y hemos cerrado por fin el P1 de Marta del Módulo 6: su mensaje de matrícula ahora se oye. La clave que llevarte: pregúntate siempre si el usuario necesita enterarse o necesita ir, y elige la herramienta adecuada.

Pero hasta ahora hemos hablado de cambios dentro de una página. ¿Qué ocurre cuando toda la aplicación es una SPA y ni siquiera la "navegación" recarga la página, sino que reescribe el DOM entero por enrutado de cliente? El lector de pantalla no anuncia la nueva "página", el foco se queda perdido y el <title> no cambia. Ese es el reto de la siguiente lección, 07-02: Accesibilidad en Aplicaciones de Página Única (SPAs), donde el panel del alumno de Cursalia será nuestro banco de pruebas.

© Copyright 2026. Todos los derechos reservados