wandres.dev
GSAP VIII · SplitText

La accesibilidad del texto partido: un problema real

Qué le hace un lector de pantalla a un titular dividido en spans, qué resuelve la opción aria, qué no resuelve, y el patrón del duplicado accesible.

⏱ 18 min

Dividir un titular en caracteres tiene un coste que no se ve en ninguna captura de pantalla ni en ninguna métrica de rendimiento: para alguien que navega con lector de pantalla, ese titular puede pasar de ser una frase a ser una ristra de letras deletreadas una por una. No es una molestia menor ni un caso teórico, y no se arregla poniendo un role al azar. SplitText incorpora una solución por defecto que funciona para la mayoría de los casos, y una alternativa para el caso que la solución por defecto no cubre. Saber cuál de las dos aplica en cada sitio es la parte que no puedes delegar.

🎯 Al terminar esta lección sabrás
  • Explicar qué anuncia un lector de pantalla ante un texto dividido sin tratar.
  • Distinguir los tres valores de la opción aria y qué hace cada uno con el DOM.
  • Reconocer el caso que aria: "auto" no cubre y aplicar el patrón del duplicado.
  • Comprobar el resultado con las herramientas que ya tienes instaladas.

Qué oye alguien que no ve tu animación

Un lector de pantalla no lee píxeles: recorre el árbol de accesibilidad, que se construye a partir del DOM. Ante esto:

<h1>Diseño</h1>

anuncia “encabezado de nivel uno, Diseño”. Ante esto, que es lo que producen la mayoría de las bibliotecas de división de texto sin más:

<h1>
  <div>D</div><div>i</div><div>s</div><div>e</div><div>ñ</div><div>o</div>
</h1>

el comportamiento depende del lector, y ninguna de las posibilidades es buena. Algunos lo tratan como seis bloques independientes y anuncian una letra por bloque, con pausa entre ellas. Otros concatenan pero introducen cortes de entonación en cada frontera de elemento, con lo que la palabra se oye troceada. Y como los elementos generados son de tipo bloque por defecto, varios lectores insertan una pausa de párrafo entre cada letra.

El resultado, en el mejor de los casos, es un titular que tarda seis veces más en leerse. En el peor, es una secuencia de letras sueltas de la que hay que reconstruir la palabra mentalmente. Con un titular de sesenta caracteres, eso es inaceptable.

Y hay un segundo efecto que también se paga: si la división afecta a un h1, los buscadores pueden acabar mostrando el titular troceado en el resultado de búsqueda. Es la misma causa —el texto ya no es un nodo de texto— con distinta víctima.

Los tres valores de aria

SplitText incorpora una solución desde la reescritura de la versión 3.13, y está activada por defecto. La opción es aria y acepta tres valores.

"auto", el valor por defecto. Pone un aria-label en el elemento dividido con su contenido de texto original, y aria-hidden="true" en todos los elementos generados. El resultado es que el árbol de accesibilidad ve un único elemento con una etiqueta de texto limpia, y los trozos son invisibles para el lector.

<h1 aria-label="Diseño">
  <div aria-hidden="true">D</div><div aria-hidden="true">i</div>
</h1>

"hidden". Pone aria-hidden tanto en el elemento dividido como en los trozos, es decir, oculta el bloque entero del árbol de accesibilidad. Solo tiene sentido si vas a proporcionar tú el texto accesible por otra vía, que es exactamente el patrón del duplicado que viene después.

"none". No toca ningún atributo. Es la opción que necesitas cuando vas a gestionar la accesibilidad tú mismo y no quieres que SplitText interfiera.

const split = SplitText.create(".titular", { type: "chars", aria: "auto" });

Lo que la opción “auto” no resuelve

El aria-label aplasta el contenido a texto plano, y con él se va toda la semántica de los elementos anidados. Si tu titular contiene un enlace, un strong, un em, un abbr o cualquier otra cosa que signifique algo, esa información desaparece del árbol de accesibilidad.

<!-- Antes: hay un enlace navegable -->
<h2>Lee la <a href="/guia">guía completa</a> antes de empezar</h2>

<!-- Despues de dividir con aria auto: el enlace ya no existe para el lector -->
<h2 aria-label="Lee la guía completa antes de empezar">
  <div aria-hidden="true">Lee</div>
  <div aria-hidden="true"><a href="/guia">guía</a></div>

</h2>

El enlace sigue en el DOM, sigue siendo enfocable con la tecla de tabulación y sigue funcionando al pulsarlo. Pero su ancestro está anunciado como una etiqueta plana, y varios lectores dejan de listarlo en su navegación por enlaces. Alguien que use el atajo de “ir al siguiente enlace” no lo encontrará.

La documentación oficial es explícita al respecto: aria: "auto" no honra la semántica ni la funcionalidad de los elementos anidados. Para texto con elementos dentro, el patrón recomendado es otro.

El patrón del duplicado accesible

Consiste en tener dos copias del contenido: una visible que se divide y se anima, oculta al lector; y otra oculta visualmente pero presente en el árbol de accesibilidad, con el marcado íntegro.

<h2 class="titular">
  <span class="visual" aria-hidden="true">
    Lee la <a href="/guia" tabindex="-1">guía completa</a> antes de empezar
  </span>
  <span class="solo-lector">
    Lee la <a href="/guia">guía completa</a> antes de empezar
  </span>
</h2>
.solo-lector {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
SplitText.create(".titular .visual", { type: "words, chars", aria: "none" });

Tres detalles importan. El aria-hidden va en la copia visual, no en la accesible. La copia visual lleva tabindex="-1" en sus enlaces para que no aparezcan dos veces en el orden de tabulación. Y la clase de ocultación no puede ser display: none ni visibility: hidden, porque ambas sacan el contenido del árbol de accesibilidad, que es justo lo contrario de lo que quieres; la técnica correcta es sacarlo de la vista manteniéndolo renderizado.

El coste es duplicar nodos, así que la documentación oficial recomienda moderación: usa este patrón donde de verdad haya semántica que preservar, no en todos los titulares por sistema.

💡
Compruébalo con lo que ya tienes

No hace falta instalar nada para verificar el resultado. El panel de accesibilidad de las herramientas de desarrollo muestra el árbol tal como lo ve un lector: si tu titular aparece como un nodo con su texto completo, has acertado; si aparecen sesenta nodos de una letra, no. En macOS, VoiceOver se activa con la combinación de la tecla de comando y F5 y te da la verificación real en treinta segundos. En Windows, NVDA es gratuito.

Reducir el movimiento también cuenta

Hay una segunda dimensión de accesibilidad en este tema que no tiene que ver con lectores de pantalla. Una animación de texto letra a letra es movimiento de alta frecuencia en el centro del campo visual, justo donde alguien está intentando leer. Para personas con sensibilidad vestibular o con dificultades de lectura, eso va de molesto a incapacitante.

La respuesta no es eliminar la animación sino sustituirla por una versión que no se mueva: un desvanecido simple, o directamente el texto ya puesto.

const mm = gsap.matchMedia();

mm.add(
  {
    normal: "(prefers-reduced-motion: no-preference)",
    reducido: "(prefers-reduced-motion: reduce)",
  },
  (contexto) => {
    const { normal } = contexto.conditions;
    const split = SplitText.create(".titular", { type: "words, chars" });

    gsap.from(normal ? split.chars : split.words, {
      yPercent: normal ? 110 : 0,
      autoAlpha: 0,
      duration: normal ? 0.7 : 0.3,
      stagger: normal ? 0.02 : 0.04,
      ease: "power3.out",
    });

    return () => split.revert();
  }
);

Ese bloque usa gsap.matchMedia(), que además de reaccionar al cambio de preferencia en caliente se encarga de limpiar todo lo que se creó dentro cuando la condición deja de cumplirse. El mecanismo completo está en la lección de matchMedia.

El texto animado es el único caso en que el contenido y su presentación se destruyen mutuamente, y por eso la accesibilidad aquí no es opcional

En casi todas las animaciones de una interfaz, el contenido y el movimiento viven en capas separadas: mueves una tarjeta, y la tarjeta sigue siendo la misma tarjeta con el mismo texto dentro. Dividir texto es el caso raro en que la técnica de presentación destruye la estructura del contenido para poder existir. No es que la animación tape el texto ni que lo mueva: es que el texto deja de ser texto y se convierte en geometría. Esa diferencia explica por qué las reglas habituales no bastan aquí. Puedes animar una tarjeta sin pensar en accesibilidad y lo peor que pasará es que alguien no perciba una transición; puedes dividir un titular sin pensar en accesibilidad y el resultado es que ese titular deja de existir como frase para una parte de tus usuarios. Y hay un agravante incómodo: es un daño que quien lo causa nunca observa. Nadie que escriba una animación de texto va a activar VoiceOver por casualidad y a descubrirlo; se descubre porque alguien lo comprueba a propósito o porque llega una queja. Por eso la solución integrada de SplitText es tan valiosa y por eso está activada por defecto: es una de las poquísimas decisiones de API que impone la opción segura sin que haya que saber que existe. Pero la responsabilidad no acaba ahí, porque el defecto seguro tiene un agujero concreto y bien documentado —los elementos anidados con significado propio— y ese agujero solo lo puedes ver tú, mirando tu contenido. La regla práctica que resume todo el capítulo cabe en una línea: si dentro del texto que vas a partir hay algo que un usuario podría querer hacer y no solo leer, la opción por defecto no te vale y necesitas el duplicado.

⚔️ Escuchar tu propia página
  1. Divide un titular sin aria, actívalo en el navegador y escúchalo con VoiceOver o NVDA. Después ponlo en "auto" y compara.
  2. Abre el panel de accesibilidad y busca tu titular dividido en el árbol. Cuenta los nodos que ve el lector.
  3. Coge un titular que contenga un enlace, divídelo con aria: "auto" y comprueba si el lector lo lista en su navegación por enlaces.
  4. Implementa el patrón del duplicado para ese titular y vuelve a comprobarlo, incluido el orden de tabulación.
  5. Activa la preferencia de movimiento reducido en tu sistema y verifica que tu animación de texto cambia de verdad.