A los sitios de documentación les encanta el cajón de navegación que se desliza. A los usuarios también les encanta —hasta que los deja atrapados en una página, provoca saltos de desplazamiento en su iPhone o hace que la navegación por teclado se sienta como espeleología sin linterna.
Si gestionas sistemas en producción (o simplemente recibes las alertas de “la documentación es inutilizable en móvil”), no quieres un cajón ingenioso. Quieres un cajón aburrido y predecible: superposición correcta, bloqueo de desplazamiento fiable y manejo del foco que no se rompa cada vez que un framework se actualiza.
Qué significa “bueno” en un cajón de docs
Un cajón de navegación móvil para documentación tiene tres trabajos, y la mayoría de las implementaciones falla al menos uno:
- Revelar la navegación sin perder el contexto: barra lateral que se desliza, jerarquía clara, cierre rápido.
- Prevenir que la página detrás interfiera: superposición real, bloqueo de desplazamiento real, sin clics fantasma.
- Respetar la entrada humana: táctil, teclado, lectores de pantalla, preferencia de reducir movimiento y casos límite raros del navegador.
“Bueno” significa que los siguientes comportamientos se cumplen en los peores dispositivos y navegadores (hola iOS Safari):
- Abrir cajón: el resto de la página queda inerte (sin desplazamiento, sin clics, sin foco).
- Cerrar cajón: la página vuelve exactamente a la posición de desplazamiento previa, y el foco regresa al botón que lo abrió.
- Teclado: Tab se mantiene dentro del cajón; Esc lo cierra; el toggle es descubrible.
- Táctil: el fondo no “se estira” bajo la superposición; sin selección accidental de texto.
- Ruteo: la navegación cierra el cajón de forma fiable y no deja el documento bloqueado.
- Rendimiento: las animaciones no causan thrash de layout; la superposición no es una hoguera de GPU.
Toma de posición: trata el cajón como un modal. No visualmente, sino en comportamiento. Si no permitirías que el fondo se desplazara detrás de un diálogo modal, no lo permitas detrás del cajón de navegación tampoco. Las páginas de docs son largas, lo que hace que los modos de fallo sean más ruidosos.
Hechos e historia que explican los problemas actuales
Saber cómo llegamos aquí hace que los bugs actuales sean menos misteriosos y más repetibles de arreglar.
- El icono “hamburguesa” no se inventó para teléfonos. Apareció en estaciones de trabajo de interfaz a principios de los 80; el móvil solo lo hizo famoso.
- Mobile Safari resistió largo tiempo el “bloqueo de desplazamiento” correcto porque el desplazamiento vivía en una ruta de compositor especializada; los desarrolladores web intentaron luchar con hacks.
- Los elementos con posición fija en iOS solían recolocarse durante mostrar/ocultar la chrome del navegador (colapso de la barra de dirección). Algunos de esos casos límite todavía aparecen como jitter.
- “100vh” históricamente mentía en móvil porque la UI del navegador ocupaba espacio dinámicamente; los cajones que asumen una altura de viewport estable se cortan o desbordan de forma extraña.
- Los contextos de apilamiento se complicaron con el crecimiento de características CSS. Propiedades como
transform,filteryopacitycrean nuevos contextos de apilamiento; las superposiciones aparecen misteriosamente debajo de los encabezados. - La web no tuvo un primitivo “inert” durante años, así que los equipos lo fingieron desactivando pointer events o interceptando el foco. El atributo
inertahora es ampliamente utilizable, pero aún requiere pruebas. - La hidratación de frameworks cambió el perfil de fallo. HTML renderizado en servidor que se vuelve interactivo más tarde puede permitir brevemente desplazamiento o foco en el fondo antes de que el JS adjunte manejadores.
- El desenfoque de fondo se puso de moda porque se ve premium, y luego todos descubrieron que también parece cuadros perdidos en dispositivos de gama media.
Una cita que vale la pena tener en la pared, porque los cajones son engañosamente “UI” y siguen siendo parte de la fiabilidad:
“La esperanza no es una estrategia.” — General Gordon R. Sullivan
No hablaba de traps de foco. Podría haberlo estado.
Arquitectura: superposición, panel y el estado como “fuente única de verdad”
La arquitectura mínima que se comporta bien:
- Botón toggle (usualmente en el encabezado): controla el estado; tiene nombre accesible claro; refleja abierto/cerrado.
- Backdrop/superposición: cubre la ventana; intercepta clics/taps; opcionalmente atenúa el fondo; mecanismo de cierre.
- Panel del cajón: elemento off-canvas que se desliza; contiene la navegación; tiene un botón de cerrar en la parte superior.
- Gestor de estado: un booleano único, más una pequeña cantidad de metadatos (qué tenía el foco antes de abrir; posición de scroll guardada).
Mantén el estado aburrido. No estás construyendo una base de datos distribuida. En el momento en que introduces “parcialmente abierto”, “arrastrando para abrir”, “abierto por hover”, pasarás tus fines de semana persiguiendo casos límite en dispositivos que no posees.
Modela tu cajón como un modal (sin complicarlo demasiado)
Usa una pequeña máquina de estados en espíritu, aunque la implementes como un par de flags:
- Cerrado: sin superposición; scroll del body normal; foco normal.
- Abriendo: establece inert/bloqueo de scroll primero, luego anima. Prevén entrada durante la transición.
- Abierto: atrapa el foco; superposición activa; cerrar con clic en superposición, botón de cierre, Esc y cambio de ruta.
- Cerrando: libera el trap de foco después de que la animación termine; restaura el scroll; restaura el foco.
El orden importa. Si animas primero y bloqueas el scroll después, los usuarios pueden desplazar la página detrás del cajón durante la animación. Lo harán. Inmediatamente.
Un chiste rápido, ya que tratamos con estados de UI
Hay dos problemas difíciles en informática: la invalidación de caché, nombrar cosas y cerrar el cajón de navegación al cambiar de ruta.
Superposición y contextos de apilamiento: por qué tu fondo está debajo del encabezado
Cuando la superposición no cubre todo, casi nunca es “z-index necesita ser más alto” en aislamiento. Son los contextos de apilamiento.
Cómo creas accidentalmente un contexto de apilamiento
Cualquiera de estos en un ancestro puede causar que tu superposición se comporte como si estuviera atrapada detrás de otra UI:
transform(incluidotransform: translateZ(0)hacks de “rendimiento”)filter/backdrop-filteropacity < 1positionmász-indexen ciertos layoutsisolation: isolatewill-change(sí, puede empujar elementos a sus propias capas y cambiar el comportamiento de composición)
En sitios de docs, el culpable habitual es un encabezado fijo con transform establecido para desplazamiento suave o micro-animaciones. Entonces tu “superposición global” ya no es global.
Guía práctica
- Renderiza la superposición y el cajón en la raíz del documento (portal a
document.body), no dentro de un contenedor transformado. - Usa una capa de apilamiento dedicada a nivel superior: por ejemplo, crea
#ui-layercomo el último hijo debody. Evita anidar dentro de los wrappers del layout principal. - No confíes en números mágicos de z-index. Establece un sistema pequeño: header 10, drawer 100, modal 1000. Luego apégate a él.
La superposición debe bloquear los pointer events de forma fiable
Usa la superposición como un elemento real que capture eventos de puntero. “Atenúa el fondo” estableciendo el color de fondo de la superposición, no aplicando opacidad a toda la página. Si atenúas la página, también atenúas el texto, y los lectores de pantalla no se inmutarán pero los humanos sí.
Bloqueo de desplazamiento: bloqueo del body, iOS Safari y la trampa de “position: fixed”
El bloqueo de desplazamiento es donde la mayoría de los cajones mueren. Los navegadores de escritorio suelen perdonar bloqueos descuidados. Los navegadores móviles recuerdan y luego te castigan con desplazamiento elástico, contenido saltón y el temido “la página vuelve arriba al cerrar”.
Lo que intentas lograr
Cuando el cajón está abierto:
- El documento detrás de la superposición no debe desplazarse.
- El propio panel del cajón puede desplazarse (las listas de navegación son largas).
- El desplazamiento táctil dentro del cajón no debe “encadenarse” hacia el body.
- Al cerrar, devolver el body exactamente a la posición de desplazamiento previa.
El patrón robusto: bloquear el body con posicionamiento fijo y scrollY guardado
Este es el patrón que funciona en la mayoría de versiones de Mobile Safari:
- Captura
scrollYal abrir. - Establece el
bodyaposition: fixed,top: -scrollY,left: 0,right: 0,width: 100%. - Al cerrar, elimina esos estilos y restaura el scroll con
window.scrollTo(0, savedScrollY).
Sí, se siente como un hack. Es un hack. Pero es un hack estable, que es por lo que pagamos.
¿Por qué no usar solo overflow: hidden en body?
Porque Mobile Safari es inconsistente con respecto al scroll del body: a veces el contenedor de desplazamiento es el elemento html, a veces es el body, a veces depende de si has establecido una altura. Puedes hacer que overflow: hidden parezca funcionar en tu dispositivo y aun así entregar una experiencia quebrada a otra persona.
Evita el encadenamiento de scroll con overscroll behavior
Para el contenedor interno de desplazamiento del cajón:
- Usa
overscroll-behavior: containdonde esté soportado para prevenir el encadenamiento de scroll hacia el body. - En iOS, considera también
-webkit-overflow-scrolling: touchpara un desplazamiento suave, pero prueba —algunas combinaciones con body fijo todavía pueden jitter.
Altura del viewport: deja de usar 100vh crudo en móvil
Prefiere unidades de viewport dinámicas (dvh) cuando estén disponibles y proporciona fallbacks. Para la altura del cajón, un enfoque común es height: 100dvh con un fallback a 100vh. Si tu pipeline CSS lo soporta, puedes mejorar progresivamente.
Segundo chiste (y último, por política y por mi propia dignidad): Mobile Safari es el único lugar donde “funciona en mi teléfono” no es una afirmación tranquilizadora.
Gestión del foco y accesibilidad: atrapar, restaurar y escapar
Los usuarios de docs incluyen usuarios de teclado, lectores de pantalla y personas que simplemente prefieren no tocar la pantalla todo el día. Si tu cajón rompe el foco, rompe el sitio para ellos.
El contrato mínimo viable de accesibilidad
- El botón toggle tiene un nombre accesible (por ejemplo, “Abrir navegación”).
- El toggle refleja el estado usando
aria-expanded="true/false". - El cajón tiene un contenedor semántico: usualmente
navo undivcon etiqueta accesible. - Al abrir, el foco se mueve al cajón (típicamente al primer elemento focalizable o a un botón de cerrar).
- El foco queda atrapado dentro del cajón mientras esté abierto.
- Al cerrar, el foco vuelve al botón toggle.
- Esc cierra el cajón.
Usa inert cuando puedas
Marcar el resto de la página como inert cuando el cajón está abierto es la forma más limpia de prevenir foco y clics en el fondo. No es magia: todavía necesitas gestionar el bloqueo de desplazamiento y una superposición visible para taps. Pero inert reduce mucho los casos extraños con el foco.
Si no puedes confiar en inert, puedes aproximarlo con:
- Agregar
aria-hidden="true"al contenido principal mientras el cajón esté abierto (pero ten cuidado: ocultar demasiado puede eliminar contexto útil para la tecnología asistiva). - Usar una implementación de focus trap que cicla el foco dentro del cajón.
- Desactivar pointer events en el contenido principal (
pointer-events: none) mientras la superposición está activa.
Focus traps: las tres reglas que previenen el 90% de los bugs
- El trap debe activarse después de que el cajón esté en el DOM y visible. Si no, el primer Tab puede escapar.
- El trap debe desactivarse incluso si el cajón se cierra por ruteo. Los cambios de ruta son donde los traps se pudren.
- Siempre restaurar el foco. Los usuarios dependen de ello. Además, es un buen canario para “¿realmente se ejecutó nuestra lógica de cierre?”
Reducir movimiento no es un “agradable de tener”
Si el usuario prefiere reducir movimiento, no deslices un panel a pantalla completa por todo el viewport. Cambia a una transformación casi instantánea o a una fundido. La animación del cajón es decorativa; la navegación es el producto.
Rutas, hidratación y ciclo de vida: cerrar el cajón en los momentos correctos
Los sitios de documentación a menudo funcionan como SPA o apps híbridas. Eso cambia cómo fallan los cajones:
- Brecha de hidratación: el HTML se renderiza, el usuario toca el menú rápidamente, los manejadores JS aún no están adjuntos. Resultado: no pasa nada o la página se desplaza.
- Transiciones de ruta: el clic de navegación cambia el contenido de la página pero deja la UI global en estado inconsistente (cajón abierto, body bloqueado).
- Restauración de scroll: los frameworks a veces restauran el scroll al cambiar de ruta, luchando con tu lógica de restauración del bloqueo.
Reglas que te mantienen cuerdo
- Cerrar al cambiar de ruta suscribiéndose a eventos del router. Esto es innegociable.
- Cerrar al cambiar de breakpoint: al pasar de navegación móvil a escritorio, forzar cierre y desbloquear scroll.
- Ser defensivo al desmontar: si el componente se desmonta mientras está abierto, la limpieza debe aún restaurar estilos del body y el estado del foco.
Hidratación: evita el “síndrome del botón muerto”
Para docs renderizados estáticamente, considera:
- Renderizar el botón toggle como un verdadero
<button>con un script inline mínimo para abrir/cerrar incluso antes de la hidratación completa (si tu plataforma lo permite). - O retrasar la visualización del toggle hasta que el JS esté listo (menos ideal; oculta la navegación brevemente).
Toma de posición: si tu documentación es la puerta de entrada de tu producto, no envíes un cajón que dependa de una carga de hidratación de 300KB para funcionar.
Rendimiento: qué no animar y por qué el blur es un impuesto
Los cajones son trampas de rendimiento porque parecen sencillos. No lo son. El cajón típico toca layout, composición, desplazamiento y manejo de eventos al mismo tiempo.
Anima transformaciones, no layout
Usa transform: translateX() para el panel, no left o width. Las animaciones basadas en layout pueden causar reflow y repaint repetidos en toda la página—especialmente doloroso en docs largos con bloques de código y resaltado de sintaxis.
Blur del fondo: trátalo como una dependencia de producción
backdrop-filter: blur() se ve genial. También obliga al navegador a re-rasterizar constantemente lo que está detrás de la superposición. En algunos dispositivos, eso no es “algo más lento.” Es “los frames caen por debajo de 30fps y la UI se siente rota.”
Si debes usar blur:
- Hazlo condicional según preferencias de reducir transparencia/movimiento.
- Limita el radio de desenfoque.
- Prueba en Android de gama media y iPhones antiguos, no solo en tu portátil.
Manejadores de eventos: no fugues, no dupliques
El código del cajón a menudo añade listeners de keydown y touchmove. Si los adjuntas en cada apertura y olvidas limpiarlos, terminarás con múltiples manejadores disparándose. Los síntomas incluyen cierres dobles, jitter y picos de CPU extraños. En producción, esto se ve como “el sitio de docs empeora cuanto más tiempo lo usas.”
Tres mini-historias corporativas desde el frente
Incidente: la suposición equivocada (“overflow: hidden es suficiente”)
Un equipo lanzó una experiencia de docs renovada con un cajón deslizante elegante. En escritorio fue perfecto. Android Chrome funcionó bien. El equipo se felicitó y pasó a los objetivos del siguiente trimestre, que es cómo invitas al caos.
En un día, el soporte empezó a recibir informes: “El menú se abre, pero la página detrás se desplaza. Luego al cerrar el menú salto a otro lugar.” Los informes eran principalmente de usuarios de iPhone, pero no todos. La primera suposición del equipo fue que era un problema de z-index de CSS, porque ese es el bug de cajón que todo el mundo conoce.
La causa real fue una sola línea: body { overflow: hidden; } alternada al abrir. “Funcionó” en sus dispositivos de prueba y falló en otros debido a diferencias en el comportamiento del contenedor de desplazamiento y la dinámica de la barra de dirección. Algunos usuarios tenían el cajón abierto mientras la página de debajo hacía rubber-band; otros vieron el body desbloquearse en momentos extraños porque la ruta cambió y el componente se desmontó sin limpieza.
Arreglarlo requirió dos cambios: (1) cambiar al bloqueo de body fijo con scrollY guardado, y (2) implementar una limpieza global en desmontaje y en cambio de ruta. La parte divertida: una vez que arreglaron el bloqueo de scroll, surgió un bug oculto de foco porque elementos del fondo todavía eran tabbables. El cajón había confiado en “los usuarios tocan la pantalla,” lo cual no es una estrategia de foco.
El incidente no fue catastrófico, pero sí reputacional. La documentación es donde los usuarios van cuando ya están frustrados. Romper la navegación en móvil es como bloquear la salida de emergencia y actuar sorprendido.
Optimización que salió mal: “acelera todo con GPU”
Otra organización quiso que sus docs se sintieran “nativos.” Alguien añadió transform: translateZ(0) y will-change: transform al encabezado y a varios wrappers de layout para “mejorar el desplazamiento.” El overlay y el panel del cajón estaban anidados dentro de uno de esos wrappers, porque así resultó la estructura del árbol de componentes.
En el camino feliz se veía suave. Luego los usuarios informaron que al tocar fuera del cajón a veces no lo cerraba. Algunos taps pasaban a través de la superposición hacia enlaces detrás. En ciertas páginas, la superposición no cubría el encabezado en absoluto. También rompió pruebas de captura de pantalla porque la composición de píxeles difería entre ejecuciones.
La causa raíz: la “optimización” creó un nuevo contexto de apilamiento y comportamiento de capas de composición que cambió el orden de hit testing. El z-index de la superposición era alto dentro de su contexto, pero el contexto en sí estaba debajo del contexto separado del encabezado fijo. En algunos navegadores, el compositor trató la superposición como visualmente encima pero aún permitió que eventos de puntero pasaran al encabezado. Eso es un tipo especial de maldición.
El rollback eliminó la mayoría de los hints de will-change. La verdadera mejora de rendimiento vino después, al reducir el peso del DOM en el árbol de navegación y diferir el resaltado de sintaxis hasta que el navegador estuviera inactivo. La animación del cajón en sí nunca fue el cuello de botella; la página lo era.
Práctica aburrida pero correcta que salvó el día: un contrato de limpieza
Un tercer equipo tenía una regla estricta: cualquier componente que mutate estado global debe proporcionar una ruta explícita de limpieza, probada por automatización. El cajón mutaba estado global: estilos del body para bloqueo de scroll, inert/aria-hidden para el contenido principal y handlers a nivel de documento.
Escribieron un pequeño módulo “UI lock” que poseía estas mutaciones. Los componentes podían solicitar un lock con un token; liberar el token restauraba el estado previo solo cuando el último token era liberado. No era elegante. Era, sin embargo, resistente a reentradas y desmontajes por cambio de ruta.
Meses después, una actualización del router cambió el timing de los eventos de transición. En otros equipos, los cajones empezaron a dejar el body bloqueado. En este equipo, el contrato de limpieza siguió ejecutándose porque estaba conectado tanto al desmontaje como a los eventos de finalización de ruta, y porque su prueba E2E afirmaba que después de la navegación el body no tenía posicionamiento fijo y que el foco aterrizaba en el contenido.
Nadie escribió un email de celebración por ello. Por supuesto que no. La UI fiable es como el almacenamiento fiable: si la gente lo nota, algo ya salió mal.
Guion de diagnóstico rápido
Cuando el cajón está roto en producción, quieres un camino rápido hacia el cuello de botella. Aquí está el orden que minimiza pérdida de tiempo.
1) ¿Es un problema de apilamiento / superposición?
- Comprobar: ¿La superposición cubre visualmente todo? ¿Intercepta los taps?
- Señal: Si los taps pasan, o el encabezado está por encima de la superposición, sospecha contextos de apilamiento y ubicación del portal.
- Dirección de arreglo inmediata: Mover overlay/panel a un portal raíz; eliminar transform/filter de ancestros; estandarizar capas de z-index.
2) ¿Es un problema de bloqueo de desplazamiento?
- Comprobar: Con el cajón abierto, ¿puedes desplazar la página detrás? ¿Cerrar salta la posición de scroll?
- Señal: Saltar al inicio tras cerrar es clásico “overflow hidden” o desajuste de bloqueo del body con height:100%.
- Dirección de arreglo inmediata: Cambiar a bloqueo de body fijo con posición de scroll guardada; asegurar limpieza en cada ruta de cierre.
3) ¿Es un problema de foco / teclado?
- Comprobar: Con teclado, ¿Tab escapa? ¿Esc cierra? ¿El foco vuelve al toggle tras cerrar?
- Señal: El escape del foco suele significar que el trap no se activó lo suficientemente pronto o el fondo no está inerte.
- Dirección de arreglo inmediata: Añadir inert o aria-hidden; implementar un focus trap fiable; restaurar foco explícitamente.
4) ¿Es rendimiento / lag?
- Comprobar: ¿Al abrir el cajón caen frames o se congela? ¿Sube la CPU?
- Señal: Blur de fondo, DOM grande en el nav, reflow forzado o duplicación de listeners de eventos.
- Dirección de arreglo inmediata: Eliminar blur, animar solo transform, reducir DOM, auditar listeners y reflows.
Errores comunes: síntoma → causa raíz → solución
La superposición no cubre el encabezado
Síntoma: El encabezado fijo sigue siendo clicable/visible por encima del fondo atenuado.
Causa raíz: La superposición está dentro de un contexto de apilamiento inferior; el encabezado creó un contexto de apilamiento separado vía transform/filter o reglas de z-index.
Solución: Renderizar overlay/panel vía portal al final de body; eliminar CSS que cree contextos de apilamiento de wrappers del layout; definir una escala de z-index.
Los taps “filtran” a través de la superposición
Síntoma: Hacer clic fuera del cajón activa enlaces detrás.
Causa raíz: La superposición tiene pointer-events: none, o no abarca la ventana, o hay un desajuste de compositing/hit-testing por transforms.
Solución: Asegurar que la superposición tenga position: fixed; inset: 0; con pointer events habilitados; evitar anidamiento dentro de padres transformados.
El fondo se desplaza bajo el cajón abierto
Síntoma: Puedes desplazar la página mientras el cajón está abierto; aparece efecto rubber-band.
Causa raíz: Usar solo overflow: hidden o bloquear el elemento incorrecto; encadenamiento de scroll desde el contenedor del cajón al body.
Solución: Usar bloqueo de body fijo con scrollY guardado; establecer overscroll-behavior: contain en el área de scroll del cajón.
Al cerrar el cajón la página salta al inicio
Síntoma: Cerrar cajón y tu posición de scroll se reinicia o desplaza.
Causa raíz: El body se configuró con position: fixed sin restaurar el scroll; o la restauración de scroll del framework pelea con tu lógica; o cambiaste la altura de html/body.
Solución: Guardar scrollY al abrir; establecer top: -scrollY; al cerrar, eliminar estilos y llamar window.scrollTo(0, savedY). Integrar con la restauración de scroll del router.
El foco del teclado escapa hacia la página de atrás
Síntoma: Presionar Tab y el foco va a enlaces del contenido principal, no al cajón.
Causa raíz: No hay trap de foco, o el trap se activa demasiado tarde, o el fondo sigue siendo enfocables.
Solución: Usar un focus trap y activarlo inmediatamente al abrir; poner inert en el contenido principal; restaurar foco al cerrar.
Esc cierra el cajón a veces, pero no siempre
Síntoma: Esc funciona una vez y luego deja de hacerlo, o solo en ciertas páginas.
Causa raíz: Listener de keydown adjunto a un componente que se desmonta; listeners duplicados; el foco está en un iframe/bloque de código que captura teclas.
Solución: Adjuntar keydown a nivel de documento durante la apertura; asegurar limpieza; ignorar Esc si se está componiendo con IME; manejar foco dentro de widgets embebidos.
El cajón se abre, pero el lector de pantalla anuncia incoherencias
Síntoma: El lector de pantalla no anuncia la navegación, o lee contenido de fondo mientras el cajón está abierto.
Causa raíz: Falta de etiquetas, uso incorrecto de aria-hidden, falta de gestión del foco, o fondo no inerte.
Solución: Etiqueta el nav; mueve el foco dentro al abrir; aplicar inert o ocultar el fondo apropiadamente; asegurar que el botón de cerrar sea accesible y tenga nombre.
Abrir el cajón es entrecortado
Síntoma: Caída de frames, lag en táctil, inicio de animación retrasado.
Causa raíz: Animar propiedades de layout; desenfoque pesado de fondo; forzar layout síncrono vía lecturas/escrituras de JS; DOM grande en el nav tree.
Solución: Animar transforms; eliminar o reducir blur; agrupar lecturas/escrituras DOM; virtualizar o colapsar secciones de la navegación.
Tareas de grado producción con comandos: comprobar, medir, decidir
Los bugs de UI aparecen como “problemas front-end”, pero diagnosticarlos se beneficia de la misma disciplina que usas para almacenamiento y fiabilidad: medir primero, cambiar después, verificar tercero. A continuación hay tareas prácticas que puedes ejecutar desde un shell mientras reproduces problemas en un entorno de staging o similar a producción.
Supuestos: tienes acceso a un host de prueba que ejecuta el sitio de docs, logs y opcionalmente un entorno de navegador sin cabeza. Los comandos son realistas y ejecutables; adapta rutas de archivo y nombres de servicio.
Task 1: Confirmar qué HTML se entrega (SSR vs cliente)
cr0x@server:~$ curl -sS -D- https://docs.internal.example/guide/install | head -n 30
HTTP/2 200
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, must-revalidate
x-powered-by: app
...
<!doctype html>
<html lang="en">
...
Qué significa la salida: Estás comprobando cabeceras de respuesta y si se entrega contenido HTML. Si ves HTML mayormente vacío con una gran referencia a un bundle de script y sin marcado de nav, tu cajón depende de la hidratación.
Decisión: Si el cajón depende de hidratación, priorizar mitigación del “botón muerto” (script inline mínimo o shell de navegación renderizado en servidor).
Task 2: Verificar que la caché no sirva versiones JS/CSS incompatibles
cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.css | egrep -i 'cache-control|etag|last-modified'
cache-control: public, max-age=31536000, immutable
etag: "a9f4c2-18b10"
last-modified: Tue, 10 Dec 2024 18:22:11 GMT
Qué significa la salida: El caching a largo plazo está bien solo si los assets tienen hashes de contenido y el HTML referencia las versiones correctas.
Decisión: Si el HTML apunta a assets no hasheados con lifetimes largos, puedes obtener “JS del cajón no coincide con HTML/CSS.” Arregla cabeceras de caché o versionado de assets.
Task 3: Inspeccionar Content Security Policy que afecte scripts inline para interactividad temprana
cr0x@server:~$ curl -sSI https://docs.internal.example/ | egrep -i 'content-security-policy'
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'
Qué significa la salida: Si planeas usar pequeños scripts inline para evitar brechas de hidratación, CSP puede bloquearlos.
Decisión: O mantén el comportamiento del cajón totalmente en JS empaquetado o actualiza CSP con una política basada en nonce. No “simplemente añadas unsafe-inline.”
Task 4: Encontrar picos de errores ligados a interacciones del cajón (errores cliente ingeridos del lado servidor)
cr0x@server:~$ sudo journalctl -u docs-web -S "2 hours ago" | egrep -i 'TypeError|Unhandled|focus|inert|scroll' | tail -n 20
Dec 29 12:11:04 web-01 docs-web[2410]: UnhandledRejection: TypeError: Cannot read properties of null (reading 'focus')
Dec 29 12:14:19 web-01 docs-web[2410]: TypeError: Failed to execute 'setAttribute' on 'HTMLElement': 'inert' is not a valid attribute name
Qué significa la salida: Bugs de restauración de foco y mal uso de inert pueden lanzar excepciones, que pueden causar cascadas como “el cajón no cierra” porque la limpieza nunca se ejecutó.
Decisión: Trata estos como problemas de fiabilidad: añade guardas, asegura limpieza en finally y detecta inert por características.
Task 5: Confirmar que el elemento overlay existe y no está siendo eliminado/minificado incorrectamente
cr0x@server:~$ curl -sS https://docs.internal.example/ | grep -n 'data-testid="nav-overlay"' | head
184: <div data-testid="nav-overlay" class="overlay" hidden></div>
Qué significa la salida: Estás verificando que la superposición esté realmente en el DOM tal como se entrega (SSR) o al menos presente en la plantilla.
Decisión: Si falta, depurar CSS no ayudará. Arregla el renderizado/templating primero.
Task 6: Comprobar bundles JS inesperadamente grandes que retrasen la hidratación
cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.js | egrep -i 'content-length|content-encoding'
content-encoding: br
content-length: 612341
Qué significa la salida: Un bundle ~600KB brotli puede ser pesado en móvil, especialmente por coste de parse/compile.
Decisión: Si la funcionalidad del cajón espera a este bundle, divide UI crítica, difiere scripts no críticos y evita que la navegación sea rehén de analytics.
Task 7: Validar compresión en servidor para CSS/JS (transferencia lenta = ventana “botón muerto” más larga)
cr0x@server:~$ curl -sSI -H 'Accept-Encoding: gzip, br' https://docs.internal.example/assets/app.js | egrep -i 'content-encoding|vary'
vary: Accept-Encoding
content-encoding: br
Qué significa la salida: La compresión está habilitada y varía correctamente por encoding.
Decisión: Si falta compresión, arréglala antes de rediseñar el cajón. Latencia es una flag de características que olvidaste añadir.
Task 8: Verificar que las rutas del cajón cierren el cajón (logs de servidor para SPA no ayudan; usa comprobaciones sintéticas)
cr0x@server:~$ node -e "console.log('Run an E2E check here with Playwright/Cypress in CI; server logs cannot see client route changes.')"
Run an E2E check here with Playwright/Cypress in CI; server logs cannot see client route changes.
Qué significa la salida: Es un recordatorio directo: no puedes diagnosticar bugs de estado cliente desde logs de acceso del servidor.
Decisión: Añade una comprobación sintética en navegador que abra el cajón, haga clic en un enlace de navegación, y aserte que el body se desbloquea y el foco aterriza en el contenido.
Task 9: Buscar mala configuración de NGINX que rompa range requests (perjudica rendimiento en móvil)
cr0x@server:~$ curl -sSI https://docs.internal.example/assets/app.js | egrep -i 'accept-ranges'
accept-ranges: bytes
Qué significa la salida: Las solicitudes por rangos pueden ayudar a algunos clientes y CDNs. No es un bug de cajón, pero afecta el tiempo hasta interactivo, que afecta la capacidad de respuesta del cajón.
Decisión: Si falta, revisa la configuración de servicio de assets estáticos. Las regresiones de rendimiento se presentan como “el menú está roto” porque los usuarios tocan antes de que existan los manejadores.
Task 10: Confirmar tiempo de respuesta y latencias tail en páginas con mucho cajón
cr0x@server:~$ curl -sS -w "ttfb=%{time_starttransfer} total=%{time_total}\n" -o /dev/null https://docs.internal.example/guide/reference
ttfb=0.142315 total=0.211904
Qué significa la salida: Si TTFB es alto, tu HTML llega tarde. Si el tiempo total es alto, tu ruta de red es lenta. Ambos aumentan la sensación de “UI muerta”.
Decisión: Si TTFB sube, arregla caching backend/render. Si la red es lenta, mejora CDN, compresión y estrategia de assets.
Task 11: Vigilar presión de memoria en servidor que causa respuestas lentas (no siempre es frontend)
cr0x@server:~$ free -h
total used free shared buff/cache available
Mem: 31Gi 26Gi 1.2Gi 352Mi 3.9Gi 4.3Gi
Swap: 2.0Gi 1.8Gi 256Mi
Qué significa la salida: Poca memoria disponible y uso de swap pueden provocar picos de latencia al servir HTML/JS, retrasando la interactividad.
Decisión: Si hay swapping, arregla presión de memoria antes de culpar al CSS. A los usuarios no les importa dónde vive el bug.
Task 12: Identificar saturación de CPU durante picos de uso de docs (de nuevo: “menú no responde” puede ser servidor)
cr0x@server:~$ uptime
12:29:44 up 18 days, 4:12, 2 users, load average: 6.21, 6.02, 5.88
Qué significa la salida: Alta carga promedio relativa a núcleos de CPU puede causar HTML lento y entrega de JS retardada.
Decisión: Si la carga es consistentemente alta, escala, cachea o reduce el coste de render. Luego vuelve a probar la “capacidad de respuesta” del cajón.
Task 13: Verificar tamaños de assets estáticos en disco para detectar builds de depuración accidentales
cr0x@server:~$ ls -lh /var/www/docs/assets | egrep 'app\.(js|css)' | head -n 5
-rw-r--r-- 1 www-data www-data 5.9M Dec 10 18:22 app.js
-rw-r--r-- 1 www-data www-data 412K Dec 10 18:22 app.css
Qué significa la salida: Si app.js es varios megabytes sin comprimir, puede que estés enviando sourcemaps o builds de desarrollo a producción.
Decisión: Arregla la pipeline de build. Los bugs del cajón se multiplican cuando el cliente lucha por parsear tu novela JavaScript.
Task 14: Comprobar si páginas de error o redirecciones de auth están cacheadas (el cajón “se rompe” porque la página no es la página)
cr0x@server:~$ curl -sSI https://docs.internal.example/guide/install | egrep -i 'http/|location:|cache-control:'
HTTP/2 200
cache-control: public, max-age=0, must-revalidate
Qué significa la salida: Si obtienes redirecciones o cabeceras de caché inesperadas, los clientes podrían ver HTML parcial o antiguo (sin scripts de nav).
Decisión: Asegura comportamiento de caché correcto para HTML y manejo adecuado de redirecciones. Que falte JS del cajón porque serviste un intersticial de login sigue siendo una caída del cajón.
Task 15: Validar que tu service worker (si existe) no sirva shell HTML obsoleto
cr0x@server:~$ grep -R "workbox" -n /var/www/docs/ | head
/var/www/docs/sw.js:12:importScripts('workbox-*.js');
Qué significa la salida: Un service worker puede cachear el app shell agresivamente y servir HTML/JS desincronizados entre versiones.
Decisión: Si usas service worker, implementa versionado y invalidación de caché apropiados. Si no, depurarás regresiones “aleatorias” del cajón que en realidad son clientes obsoletos.
Task 16: Buscar adjunción duplicada de handlers de evento en el bundle (grep rápido)
cr0x@server:~$ grep -R "addEventListener(\"keydown\"" -n /var/www/docs/assets/app.js | head
12877:document.addEventListener("keydown",u)
Qué significa la salida: No prueba fugas, pero te dice dónde se enlazan handlers de teclas. Si tu app añade listeners en cada apertura sin removerlos, verás múltiples llamadas en tiempo de ejecución.
Decisión: Audita ciclo de vida y limpieza. Añade contadores de instrumentación si es necesario. Las fugas de eventos de UI son la prima de las fugas de descriptores de archivo: ignoradas hasta que muerden.
Listas de verificación / plan paso a paso
Plan de implementación paso a paso (la versión aburrida que funciona)
- Coloca la superposición y el cajón en una capa UI a nivel superior anexada a
body(portal). Evita ancestros transformados. - Crea una escala de z-index y hazla cumplir en revisión de código. Si alguien añade
z-index: 999999, haz que expliquen sus elecciones de vida. - Implementa bloqueo de desplazamiento usando scrollY guardado + body fijo. Incluye limpieza en cada ruta de salida.
- Haz del panel del cajón su propio contenedor de scroll con containment de overscroll.
- Implementa gestión del foco: guarda elemento activo, mueve foco al cajón al abrir, atrapa foco, restaura al cerrar.
- Implementa mecanismos de cierre: clic en superposición, botón de cerrar, Esc, cambio de ruta, cambio de breakpoint.
- Añade soporte para reducir movimiento para evitar transiciones pesadas en movimiento.
- Prueba en iOS Safari con páginas largas y una posición de scroll profunda. No aceptes “funciona en el emulador de Chrome”.
- Añade comprobaciones E2E que verifiquen restauración de bloqueo de scroll y restauración de foco a través de la navegación.
- Instrumenta errores desde rutas de código de foco/scroll; trátalos como problemas de disponibilidad.
Checklist pre-merge (qué exigir en la revisión)
- La superposición es
position: fixedcon cobertura completa del viewport. - No hay overlay/drawer dentro de un wrapper de layout transformado.
- El bloqueo del body guarda y restaura la posición de scroll de forma determinista.
- Todas las rutas de cierre llaman a la misma función de limpieza.
- El foco se restaura al botón toggle después de cerrar.
- El cajón tiene un botón de cerrar accesible por teclado.
- Esc cierra; Tab no escapa.
- Se respeta reducir movimiento.
- El cambio de ruta cierra el cajón y desbloquea el scroll.
- Sin blur/backdrop filter sin aprobación de pruebas de rendimiento.
Checklist de lanzamiento (realidad en producción)
- Comprobar cabeceras de caché: HTML no sobre-cacheado; assets inmutables con hashes.
- Verificar que los budgets de tamaño de bundle no hayan regresado el tiempo a interactivo.
- Ejecutar prueba sintética de navegación móvil en CI y después del despliegue.
- Monitorear ingestión de errores cliente para excepciones de foco/scroll.
- Tener un camino de rollback que también invalide la caché del service worker si se usa.
FAQ
1) ¿Debe un cajón de navegación para docs ser un <dialog>?
Usualmente no. Trátalo como un modal en comportamiento, pero semánticamente es navegación. Usa un contenedor nav, superposición y atrapado de foco. <dialog> puede funcionar, pero introduce sus propias rarezas y restricciones de estilo.
2) ¿Es seguro usar inert?
Es ampliamente usable ahora, pero aun así detecta la característica y prueba. Si no puedes confiar en él en todos los entornos que te importan, recurre a un focus trap más gestión cuidadosa de aria. No lances algo que bloquee clics pero permita foco en el fondo.
3) ¿Por qué mi página salta cuando cierro el cajón?
Porque tu bloqueo de scroll no restauró la posición correctamente, o la restauración de scroll del framework peleó contigo. Guarda scrollY al abrir, bloquea el body con posicionamiento fijo y top: -scrollY, luego restaura scrollY al cerrar y coordina con el comportamiento del router.
4) ¿Realmente necesito atrapar el foco para un cajón de navegación?
Sí, si se comporta como una superposición que bloquea la página. Si no, los usuarios de teclado pueden tabular hacia contenido invisible u oscurecido detrás del cajón. Eso no es “algo molesto”, está roto.
5) ¿Por qué la superposición queda debajo de algunos elementos aun con z-index alto?
Porque z-index está acotado a contextos de apilamiento. Si tu superposición vive dentro de un contexto de apilamiento que está por debajo del contexto del encabezado, no puede superarlo en z-index. Arregla la ubicación en el DOM (portal) y elimina triggers que crean contextos de apilamiento en ancestros.
6) ¿Debe el cajón cerrarse cuando se hace clic en un enlace de navegación?
Sí. Siempre. Cierra inmediatamente al clic (optimista), luego deja que ocurra el ruteo. También cierra en eventos de cambio de ruta por si la navegación ocurre por otros medios (programática, atrás/adelante, cambios de hash).
7) ¿Vale la pena el desenfoque del fondo?
Sólo si puedes demostrar que no destroza el rendimiento en dispositivos representativos. El blur es un coste constante durante la animación y mientras está abierto. Una simple superposición semitransparente es más barata y predecible.
8) ¿Cómo manejo árboles de navegación anidados sin volver el cajón inutilizable?
Usa divulgación progresiva: colapsa secciones por defecto, conserva el estado expandido del usuario por sesión y mantén la ruta activa expandida. Y mantén el DOM de la navegación más ligero que tu ego—los árboles profundos pueden ser caros de renderizar y desplazar.
9) ¿Qué hay de los gestos de deslizar para abrir/cerrar el cajón?
Sé cuidadoso. Los gestos entran en conflicto con la navegación del navegador y el desplazamiento. Si implementas swipe, hazlo opt-in y mantenlo secundario a controles explícitos. Tu trabajo primario es “abrir con fiabilidad”, no “parecer un demo de app nativa”.
10) ¿Cómo pruebo esto correctamente?
Ejecuta pruebas E2E que aserten: la superposición cubre el viewport, el fondo no se desplaza, el foco se mueve al cajón, Tab se queda dentro, Esc cierra, el foco vuelve al toggle y el cambio de ruta desbloquea scroll. Luego prueba manualmente en iOS Safari con una página larga y mucho scroll.
Conclusión: próximos pasos que sobreviven en producción
Un cajón de docs móvil no es un adorno de diseño. Es infraestructura. Determina si los usuarios pueden escapar de una página, encontrar la guía correcta y mantener su lugar mientras depuran algo a las 2 a.m.—que, incidentalmente, también es cuando depurarás tu cajón si lo envías descuidado.
Haz esto a continuación:
- Mueve overlay/cajón a un portal de capa superior y estandariza z-index.
- Implementa bloqueo de body fijo con posición de scroll guardada y limpieza a prueba de balas.
- Añade inert (o equivalente) y un focus trap real; restaura el foco al cerrar.
- Cierra al cambio de ruta y al cambio de breakpoint—siempre.
- Ejecuta una prueba E2E sintética que falle en alto cuando el body quede bloqueado o el foco escape.
Envía el cajón aburrido. Tus usuarios nunca te lo agradecerán, y esa es la señal de que funcionó.