# Pretext: demo accessible

> Rehice el demo del editorial engine de Cheng Lou con Pretext como una app React conforme con WCAG 2.2: orbes arrastrables por teclado, soporte para lectores de pantalla, prefers-reduced-motion, y un fallback HTML nativo al 150%+ de zoom o en viewports menores a 500px.

*By Micaela Avigliano — Frontend & Accessibility Engineer · Publicado 19 abr 2026*

[Read the original on micaavigliano.com](https://micaavigliano.com/es/blog/pretext-demo-accesible)

---

## [Qué hice](#que-hice)

Rehice el demo de [Pretext](https://github.com/chenglou/pretext), creado por Cheng Lou, donde se muestra un "[editorial engine](https://somnai-dreams.github.io/pretext-demos/the-editorial-engine.html)" (motor editorial?) e hice [mi propio demo completamente accessible](https://the-editorial-engine-a11y.netlify.app/). Mi demo incluye estructura HTML semántica, operabilidad por teclado, soporte para lectores de pantalla y soporte de `prefers-reduced-motion`. El resultado es una demo accesible y performante que cumple con los criterios de la WCAG 2.2 sin comprometer la estética ni el rendimiento. Las orbes siguen siendo arrastrables con el mouse, pero con el agregado de que ahora también se pueden mover usando el teclado.

Este demo utiliza un **sistema de renderizado dual**. Cuando no hay zoom, pretext crea una capa visual donde las líneas de texto se adaptan dinámicamente a la posición de las orbes que se mueven por todo el documento. Por debajo de esta capa, hay un elemento de HTML nativo `<article>` que con la ayuda de la propiedad de CSS `column-count` permanece de forma estática en el DOM para que lectores de pantalla puedan anunciar su contenido de manera fluída, para que los usuarios puedan copiar el texto, pegarlo en otras aplicaciones o usar la funcionalidad de búsqueda en la página, etc. Cuando el usuario hace zoom en el navegador por encima del 150% o el ancho de la pantalla es menor a 500px, la capa visual de pretext se elimina completamente y el artículo HTML nativo se convierte en el diseño visible. Finalmente, la letra inicial del texto cuando no hay zoom o el viewport es amplio utiliza pretext para su estilado, mientras que cuando hay zoom o la pantalla es pequeña, el estilado de la letra inicial se maneja utilizando la propiedad nativa de CSS `::first-letter`.

## [Uso de la librería Pretext](#uso-de-la-libreria-pretext)

La librería Pretext es el protagonista detrás del renderizado y el layout del texto. Usé las siguientes APIs:

| API | Propósito | Donde se usa |
| --- | --- | --- |
| `prepareWithSegments()` | Mide y almacena el ancho de las palabras para cada párrafo | Preparar el texto cuando se carga la fuente |
| `layoutNextLine()` | Cada línea de texto se acomoda en un ancho máximo dado | Organiza las columnas del layout, se llama por línea o por espacio disponible |
| `layoutWithLines()` | Acomoda todas las líneas de texto en un bloque de texto ya preparado | Ordena los encabezados |
| `walkLineRanges()` | Itera sobre los rangos de líneas sin asignar objetos de línea | Búsqueda binaria de encabezados, ancho de capítulos iniciales |

## [Mejoras de accesibilidad](#mejoras-de-accesibilidad)

### [Estructura de HTML semántica](#estructura-de-html-semantica)

Esta aplicación utiliza elementos semánticos y landmarks de HTML5:

-   `<header>`: elemento que agrupa los controles de interacción de la página
-   `<main>`: contenido principal (texto y orbes)
-   `<article lang="es">`: elemento independiente que contiene el texto legible para usuarios visuales y el texto accessible para usuarios de tecnologías asistivas con el idioma español
-   `<section>`: región donde se contienen las orbes
-   `<footer>`: elemento que contiene las estadísticas de rendimiento y los créditos
-   `<kbd>`: representación semántica en HTML de un dispositivo de entrada de texto (teclado, dispositivo de voz, etc).
-   `<cite>`: cita el título de una obra.
-   `<nav>`: sección de atajos de teclado y navegación de créditos.

### [Accessibilidad por teclado](#accessibilidad-por-teclado)

Cada elemento interactivo es operable y navegable con el teclado

| Acción | Tecla | Con lector de pantalla |
| --- | --- | --- |
| Pausar globalmente las orbes | `Esc` | `Esc` |
| Navegar a las orbes | `Tab` | `Tab` |
| Mover la orbe seleccionada | `Arrow keys` | `Option + Arrow keys` |
| Pausar/continuar orbe individualmente | `Space` | `Space` |

Las orbes son elementos `<button>` nativos, por lo que se anuncian como botones, reciben foco acorde con el orden lógico de tabulación se pueden operar con `Space`/`Enter` para pausar/reanudar y mover usando las `Arrow keys`. `aria-roledescription="draggable orb"` complementa el anuncio del rol para que el usuario entienda que no son botones ordinarios. El `aria-label` de cada orbe es dinámico y codifica su posición en el conjunto, las interacciones disponibles y su estado actual de pausa.

### [Accessibilidad con lectores de pantalla](#accessibilidad-con-lectores-de-pantalla)

Cuando un usuario de lector de pantalla navega a una orbe, percibe: "Golden orb, 1 of 5. Use Option plus arrow keys to move. Press Space to pause." (traducción: "Orbe dorada, 1 de 5. Use la tecla Option más flechas para mover. Presione Espacio para pausar.") La instrucción está integrada en el `aria-label` de las orbes porque los lectores de pantalla tienen su propia navegación con las teclas de fecha y no podemos sobreescribirlas, por eso debemos crear una nueva combinación de teclas para evitar conflictos en la navegación. En este caso, para mover las orbes, utilizamos la combinación `Option + Arrow`. Esto mantiene el modelo de interacción estándar del lector de pantalla intacto y hace que las capacidades de las orbes sean auto-descriptivas, sin sacrificar el control por teclado.

Cuando la orbe esta pausada, la etiqueta cambia a "Press Space to resume." ("Presione Espacio para reanudar.") para que el usuario siempre sepa la acción actual disponible sin tener que adivinar.

### [Soporte de lectores de pantalla y ARIA](#soporte-de-lectores-de-pantalla-y-aria)

-   **`aria-live="polite"`**: anuncia cualquier cambio en el estado de la orbe (pausa/reanudación) sin interrumpir al usuario.
-   **`aria-pressed`**: comunica el estado de los botones de alternancia (encendido/apagado)
-   **`aria-label`**: cada elemento interactivo tiene una etiqueta descriptiva y dinámica

### [Soporte para `prefers-reduced-motion`](#soporte-para-prefers-reduced-motion)

Cuando el usuario tiene habilitada la preferencia de reducción de movimiento (`prefers-reduced-motion`) en su sistema operativo:

-   Todas las orbes dejan de moverse
-   El texto deja de adaptarse dinámicamente a la posición de las orbes y se mantiene fijo
-   CSS `scroll-snap-type` y `scroll-behavior: smooth` están deshabilitados
-   Las transiciones y animaciones de CSS son suprimidas globalmente via `animation-duration: 0.01ms !important`
-   El usuario puede anular la reducción de movimiento en la aplicación volviendo a activar la animación con el botón de toggle

### [Control de pausado de las animaciones](#control-de-pausado-de-las-animaciones)

-   **Pausa global** (botón o `Esc`): Pausa todas las animaciones. Las orbes quedan fijas donde estaban en el documento y no se mueven si el usuario desplaza la página.
-   **Pausa individual** (click o `Space` en una orbe individual): Solo la orbe seleccionada deja de moverse por el documento. Las demás orbes continúan moviéndose. Aún así la orbe pausada puede ser arrastrada por el usuario através del documento mediante las arrow keys o el mouse.
-   El botón de pausa refleja el estado agregado. Si todas las orbes están pausadas individualmente, muestra "Play"; si todas están en movimiento, muestra "Pause".

## [Comportamiento del texto y pantalla según el tamaño de la pantalla y el nivel de zoom](#comportamiento-del-texto-y-pantalla-segun-el-tamano-de-la-pantalla-y-el-nivel-de-zoom)

### [Renderizado dual con pretext y HTML nativo](#renderizado-dual-con-pretext-y-html-nativo)

-   **Capa visual de pretext** (`aria-hidden="true"`): Cada línea es un `<div>` con una posición absoluta manejada por `layoutNextLine()` que envuelve a las orbes como si fuesen obstáculos circulares. La altura de la capa se calcula a partir de la posición más baja de la línea.
-   **elemento nativo de HTML: `<article>`**: cuando la capa de pretext está activa, el `<article lang="es">` se oculta visualmente con `position: absolute; left: -9999px` (no `display: none`, para que los lectores de pantalla puedan leerlo).
-   el estado `useNativeLayout` se obtiene a partir de la división entre `window.outerWidth / window.innerWidth` para decidir cuál de los dos modos es el que se renderizará.

| Ancho de la pantalla | Columnas |
| --- | --- |
| \> 1000px | 3 columnas |
| 641-1000px | 2 columnas |
| < 640px | 1 columna |

### [Reubicación dinámica del texto alrededor de las orbes](#reubicacion-dinamica-del-texto-alrededor-de-las-orbes)

Cuando la capa de Pretext está activa, la posición de cada orbe y su radio se convierten en un obstáculo circular a través de `orbToObstacle()`. El motor de layout (`layoutAllText()`) los alimenta dinámicamente a través de la función `layoutNextLine()`, que acorta o desplaza las líneas para que puedan acomodarse alrededor de las orbes.

### [Encabezados dinámicos con `fitHeadline()`](#encabezados-dinamicos-con-fitheadline)

Los encabezados se acomodan dinámicamente utilizando la función `fitHeadline()` que utiliza `layoutWithLines()` de pretext para encontrar el tamaño de fuente más grande que permita que el encabezado se ajuste dentro del ancho disponible y una altura máxima (35% del viewport, o 20% en pantallas pequeñas). Cada línea del encabezado se renderiza como un elemento separado con posición absoluta.

### [Primera letra decorativa (drop cap) del primer párrafo](#primera-letra-decorativa-drop-cap-del-primer-parrafo)

Cuando la capa de Pretext está activa, la primera letra del primer párrafo se renderiza como un `<div>` posicionado que abarca 3 líneas de altura. El motor de layout reserva una región rectangular para el drop cap y hace que el texto del primer párrafo fluya alrededor de él. En modo nativo,se utiliza la propiedad nativa de CSS `::first-letter` que proporciona el mismo efecto.

### [Uso dinámico del header](#uso-dinamico-del-header)

El elemento `<article>` tiene un `padding-top` como valor tiene una propiedad de CSS customizada `--header-h`. Esta propiedad depende de [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). Cuando la capa de Pretext está activa, el header se calcula con la fórmula `Math.max(GUTTER, headerHeight + 8)` para asegurar que el contenido del `<article>` nunca quede oculto detrás del header fijo, sin importar el nivel de zoom o el tamaño del viewport.

## [Comportamiento en pantallas pequeñas y con zoom >= 150%](#comportamiento-en-pantallas-pequenas-y-con-zoom-150)

### [Las orbes y la capa de Pretext se eliminan completamente](#las-orbes-y-la-capa-de-pretext-se-eliminan-completamente)

Cuando el ancho de la pantalla es menor a 500px o el zoom del navegador es mayor o igual al 150%, React desmonta condicionalmente la sección de las orbes y la etapa de pretext por completo (no `display: none`), y `useNativeLayout` también detiene el loop de `renderFrame` para que no se ejecute ningún trabajo de layout en segundo plano.

El zoom se detecta mediante `window.outerWidth / window.innerWidth`: cuando el usuario hace zoom in, `innerWidth` se reduce mientras que `outerWidth` permanece constante, lo que da la relación de zoom real.

### [El header cuando el nivel de zoom es alto o la pantalla es pequeña](#el-header-cuando-el-nivel-de-zoom-es-alto-o-la-pantalla-es-pequena)

Cuando el ancho de la pantalla es menor a 500px o el zoom del navegador es mayor o igual a 200%, el header se compacta y se convierte en un elemento colapsable utilizando los elementos nativos de HTML `<details>` y `<summary>` para prevenir que el header consuma espacio necesario para el contenido principal.

La API `ResizeObserver` sigue activa incluso en esta vista, por lo que el `padding-top` del artículo se ajusta dinámicamente a la altura del header incluso cuando está colapsado, asegurando que el contenido principal siempre sea visible y no quede oculto detrás del header.

### [El footer y el header en pantallas pequeñas](#el-footer-y-el-header-en-pantallas-pequenas)

Cuando el ancho de la pantalla es menor a 640px:

-   El header se convierte en un elemento colapable utilizando `<details>`/`<summary>`
-   El footer se ordena verticalmente (solo créditos)
-   El texto de los fotos y el tamaño de las letras de las recomendaciones se reducen
-   El texto se convierte en una sola columna

## [Comportamiento de desplazamiento (scrolling)](#comportamiento-de-desplazamiento-scrolling)

`html { scroll-snap-type: y proximity; scroll-behavior: smooth; }` hace que el desplazamiento se acerque a alineaciones de snap sin atrapar el desplazamiento por teclado (`Space`, `Page Down`, flechas siguen funcionando porque el tipo es `proximity`, no `mandatory`). Si `prefers-reduced-motion: reduce` está activado a nivel de sistema operativo, ambas propiedades vuelven a `auto` / `none`.

## [WCAG success criteria](#wcag-success-criteria)

The following WCAG 2.2 success criteria are relevant to this project:

| Criterion | Level | Status | Notes |
| --- | --- | --- | --- |
| [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | A | Pasa | La estructura semántica transmite la información de forma programática |
| [1.3.2 Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Pasa | El orden del DOM coincide con el orden de lectura visual |
| [1.3.4 Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA | Pasa | El diseño se adapta a los modos vertical y horizontal |
| [1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | A | Pasa | El estado de pausa utiliza opacidad + anuncio SR, no solo color |
| [1.4.3 Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) | AA | Pasa | Todo el texto cumple con la proporción 4.5:1 |
| [1.4.4 Resize Text](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html) | AA | Pasa | El texto se reubica al hacer zoom al 200%; las orbes se eliminan al hacer zoom al 150%+ para prevenir obstrucción |
| [1.4.10 Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) | AA | Pasa | Single column at narrow viewports, no horizontal scroll |
| [1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) | AA | Pasa | Los indicadores de foco cumplen con la proporción 3:1 contra los colores adyacentes |
| [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) | A | Pasa | todo es accesible por teclado |
| [2.1.2 No Keyboard Trap](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html) | A | Pasa | `scroll-snap-type: proximity` (not `mandatory`) previene el focus trap |
| [2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Pasa | manejo global de la pausa |
| [2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Pasa | contempla `prefers-reduced-motion` |
| [2.4.1 Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Pasa | Landmark navigation via `<header>`, `<main>`, `<footer>` |
| [2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Pasa | Orden de tabulación lógico |
| [3.1.1 Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Pasa | El texto en inglés tiene el atributo `lang="en"` y el documento tiene el atributo `lang="en"` |
| [3.1.2 Language of Parts](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | AA | Pasa | El texto en español tiene el atributo `lang="es"` |
| [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Pasa | Los botones tienen etiquetas, los botones de alternancia tienen `aria-pressed`, las regiones vivas anuncian cambios |

## [Tech stack](#tech-stack)

-   [Claude](https://claude.ai/) calculos.
-   [React](https://react.dev/) 19
-   [TypeScript](https://www.typescriptlang.org/) 5.9
-   [Vite](https://vite.dev/) 8
-   [@chenglou/pretext](https://github.com/chenglou/pretext) 0.0.3
-   [Atkinson Hyperlegible](https://brailleinstitute.org/freefont), font diseñado por el Braille Institute para obtener la máxima legibilidad.
