Encabezado fijo que se oculta al desplazar: enfoque CSS primero + fallback JS mínimo
Desplegaste un encabezado fijo. Marketing lo adoró. Los usuarios no. Ahora cada desplazamiento se siente como empujar un frigorífico cuesta arriba: entrecortado, errático y, a veces, cubriendo precisamente la interfaz que debería facilitar el acceso.
El objetivo aquí es simple: un encabezado que esté disponible cuando el usuario desplaza hacia arriba (útil) y que se esconda cuando el usuario desplaza hacia abajo (cortés). Lo haremos con prioridad en CSS, porque CSS es más estable bajo carga. Luego añadiremos un JavaScript mínimo como fallback para las partes que CSS no puede inferir: la dirección del desplazamiento y «¿realmente llegamos a la parte superior?».
Lo que realmente quieres (y lo que no)
Un “encabezado fijo que se oculta al desplazar” suena como un adorno de diseño. En producción, es un lazo de control. Estás midiendo la posición de desplazamiento, infiriendo la intención del usuario y mutando el layout o el pintado en respuesta. Eso es un problema de fiabilidad con disfraz.
Aquí está la especificación práctica que recomiendo:
- En la parte superior de la página: el encabezado es visible, no elevado (sin sombra), no “rebota”.
- Desplazando hacia abajo: el encabezado se oculta tras un pequeño umbral (evita parpadeos en micro-desplazamientos).
- Desplazando hacia arriba: el encabezado reaparece rápidamente (la navegación y búsqueda vuelven a ser útiles).
- Anclas / navegación interna: los encabezados de contenido no quedan cubiertos por el encabezado.
- Reducción de movimiento: no hay animación deslizante si el sistema operativo pide menos movimiento.
- Mobile Safari: no hace jitter, no atrapa toques y respeta las áreas seguras.
- Presupuesto cero de CLS: el encabezado no debe causar cambios de diseño una vez renderizado.
Lo que no quieres:
- Ocultar/mostrar cambiando
heightodisplaydurante el desplazamiento. Eso implica trabajo de layout. Lo pagarás en cada frame. - Un manejador de scroll que haga más que setear un booleano. El navegador ya tiene el trabajo de dibujar píxeles.
- “Funciona en mi MacBook Pro” como criterio de rendimiento. El teléfono medio no comparte tus sentimientos.
Una regla para tatuar en tu proceso de code review: anima transformaciones, no el layout. Si tu implementación provoca thrash de layout, eventualmente encontrará una página con contenido pesado y perderá.
Hechos y breve historia: por qué esto es más difícil de lo que parece
Los encabezados fijos parecen un problema resuelto porque los has visto mil veces. Bajo el capó, son un apretón de manos entre la mecánica de desplazamiento, la composición, las peculiaridades del viewport y las expectativas de accesibilidad. Algunos puntos de contexto rápidos que importan cuando depuras esto a las 2 a.m.:
position: stickyse estandarizó después de años de experimentos de proveedores. El comportamiento “sticky” inicial a menudo dependía de bibliotecas JS que rellenaban todo mediante escuchas de scroll.- Los navegadores móviles tienen, en la práctica, dos viewports. El viewport de layout y el viewport visual pueden diferir (especialmente con la colapsada barra de direcciones), lo que afecta las expectativas de
top: 0. - iOS Safari históricamente tuvo problemas con fixed/sticky durante el rebote (rubber-band). Incluso hoy, los comportamientos de overscroll y la barra dinámica pueden producir jitter en casos límite.
- Los eventos de scroll fueron una vez síncronos y costosos. Los navegadores avanzaron hacia el scroll asíncrono para mantener la UI responsiva, por eso los manejadores modernos de scroll deben ser livianos y a menudo pasivos.
IntersectionObserverse introdujo para evitar sondeos constantes del scroll. Es clave para la lógica “¿he pasado un centinela?” sin quemar CPU por píxel.- Core Web Vitals puso números detrás de “se siente mal”. CLS e INP te delatarán incluso si tu equipo de QA pasó por alto el jitter.
- Existe el “anclaje de scroll” para prevenir saltos de contenido. Pero los encabezados que cambian de altura pueden derrotarlo y reintroducir saltos que los usuarios interpretan como roturas.
- Las áreas seguras (safe area insets) se volvieron una preocupación web con los teléfonos con notch. Si tu encabezado ignora
env(safe-area-inset-top), tendrás contenido recortado en algunos dispositivos.
Broma #1: Un encabezado fijo es como una cuota de almacenamiento: nadie lo nota hasta que falla, y entonces de repente es la máxima prioridad de todos.
Enfoque CSS primero: sticky, offsets seguros y sin cambios de diseño
El enfoque CSS primero significa: consigue el 80% del comportamiento sin JavaScript. Eso te da una base estable: sin dependencia de manejadores de scroll, sin sorpresas cuando el main thread está ocupado y menos incidentes de “solo pasa en esta página”.
CSS base para el encabezado (sticky + animación por transform)
Usa position: sticky y mantén la altura del encabezado constante. Al ocultarlo, trasládalo fuera de la vista con transform. Esto es amigable con la composición y usualmente evita recálculos de layout.
El encabezado de esta página ya implementa la base: posicionamiento sticky, altura constante, ocultado por transform, padding de safe-area y :target para evitar solapamiento de anclas.
Evitar que objetivos de ancla queden ocultos bajo el encabezado
Si tu navegación interna usa objetivos #hash (enlaces del TOC, “ir a sección”), el navegador desplaza el objetivo hacia arriba. Con un encabezado fijo, “arriba” queda detrás de una losa de UI.
La solución de baja tecnología es sólida: scroll-margin-top en los encabezados o una regla global :target. Eso es lo que usamos:
cr0x@server:~$ cat ui.css | sed -n '1,40p'
:target {
scroll-margin-top: calc(var(--header-h) + 16px);
}
Significado de la salida: estás añadiendo margen de desplazamiento para cualquier elemento que sea objetivo de una navegación por fragmento. Decisión: aplica esto globalmente si la estructura del documento es consistente; de lo contrario, aplícalo a h2, h3 para evitar offsets extraños en otros objetivos.
No dejes que el contenido salte cuando el encabezado “se oculta”
Si ocultar el encabezado cambia su altura, el contenido de la página se desplaza hacia arriba. Eso es CLS clásico. Los usuarios lo perciben como “el sitio se mueve debajo de mi dedo”. Mantén la altura fija. Oculta con transform.
Esto también evita un modo de fallo donde el encabezado se oculta, el contenido se desplaza y el navegador intenta mantener el ancla de scroll visible, causando saltos adicionales. Es como dos sistemas de control peleando en el aire.
Respeta la preferencia de reducción de movimiento por defecto
Los encabezados deslizantes pueden desencadenar reacciones para algunos usuarios. Hazlo instantáneo bajo prefers-reduced-motion: reduce. Aún puedes alternar la visibilidad; solo omite la transición animada.
Tres patrones viables (elige uno con propósito)
Patrón A: “Sticky siempre visible” (solo CSS, aburrido, confiable)
Esto no es lo que promete el tema, pero es la base desde la que deberías empezar. Un encabezado sticky que nunca se oculta tiene menos piezas móviles y suele ofrecer mejor accesibilidad. Si tu encabezado es alto o tu contenido es denso, puede seguir siendo la mejor decisión de producto.
- Pros: el más simple, menos jank, más fácil de razonar.
- Contras: consume espacio vertical, especialmente doloroso en pantallas pequeñas.
Patrón B: “Ocultar al bajar, mostrar al subir” (JS mínimo, lo mejor en general)
Este es el comportamiento estándar que los usuarios esperan porque coincide con la intención: si están leyendo hacia abajo, apárate; si invierten la dirección, probablemente buscan navegación.
- Pros: funciona en todas partes, predecible, se puede ajustar con umbrales.
- Contras: requiere JS para inferir la dirección; hay que tener cuidado con el rendimiento.
Patrón C: “Ocultar después de un centinela, mostrar cerca de la cima” (IntersectionObserver + dirección opcional)
Si odias los listeners de scroll (razonable), usa un elemento centinela cerca de la parte superior. Cuando sale del viewport, eleva el encabezado (sombra) y opcionalmente habilita la lógica de ocultado. Esto hace que el estado “en la parte superior” sea robusto.
- Pros: menos cálculos de scroll; detección estable de “estoy en la parte superior”.
- Contras: sigue necesitando detección de dirección para el comportamiento completo de ocultar-al-bajar; puede complicarse con barras dinámicas.
Fallback JS mínimo: dirección, umbrales y estado
CSS no puede conocer la dirección del desplazamiento. Puede reaccionar a un estado que tú establezcas. Así que la forma correcta es: JS lee la posición de scroll, establece un par de atributos data y se aparta.
Mantén la máquina de estados pequeña:
data-hidden: booleanodata-elevated: booleano (sombra una vez que no estás en la parte superior)
Un script mínimo apto para producción
Este es el JS mínimo que estoy dispuesto a defender en una revisión de rendimiento. Usa requestAnimationFrame para coalescer eventos de scroll, un umbral para evitar parpadeos y evita hacer trabajo cuando nada cambió.
cr0x@server:~$ cat sticky-header.js
(() => {
const header = document.querySelector('.site-header');
if (!header) return;
const hideThreshold = 12; // px of downward movement before hiding
const showThreshold = 6; // px of upward movement before showing
const elevateAfter = 4; // px from top before adding shadow
const topSnap = 0; // treat 0 as top; adjust for visual viewport if needed
let lastY = window.scrollY || 0;
let lastDir = 0; // -1 up, +1 down
let rafPending = false;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
function update() {
rafPending = false;
const y = window.scrollY || 0;
const dy = y - lastY;
// Determine direction with deadzone to avoid noise.
let dir = lastDir;
if (dy >= hideThreshold) dir = 1;
else if (dy <= -showThreshold) dir = -1;
// Elevated when not at top.
const elevated = y > elevateAfter;
// Hide only when scrolling down and not near top.
let hidden = header.dataset.hidden === 'true';
if (y <= topSnap) {
hidden = false;
} else if (dir === 1) {
hidden = true;
} else if (dir === -1) {
hidden = false;
}
// Apply only on changes.
if ((header.dataset.elevated === 'true') !== elevated) {
header.dataset.elevated = elevated ? 'true' : 'false';
}
if ((header.dataset.hidden === 'true') !== hidden) {
header.dataset.hidden = hidden ? 'true' : 'false';
}
lastY = y;
lastDir = dir;
}
function onScroll() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(update);
}
window.addEventListener('scroll', onScroll, { passive: true });
// Run once on load in case the page loads mid-scroll.
update();
})();
Significado de la salida: ahora tienes un conmutador determinista que no se ejecutará más de una vez por frame de animación. Decisión: usa esta forma exacta si te importa el rendimiento; no dejes que crezca tentacularmente.
Centinela con IntersectionObserver: “estado en la cima” robusto sin adivinar
Los bugs más extraños en estos encabezados vienen de la lógica “¿estamos en la parte superior?” bajo barras móviles. Un elemento centinela en la parte superior del contenido te da una señal nítida: si intersecta, estás en/cerca de la parte superior.
Puedes combinar el centinela con el script de dirección anterior, o usarlo solo para la elevación y evitar por completo el parpadeo de la sombra.
cr0x@server:~$ cat sentinel.js
(() => {
const header = document.querySelector('.site-header');
const sentinel = document.querySelector('[data-top-sentinel]');
if (!header || !sentinel || !('IntersectionObserver' in window)) return;
const io = new IntersectionObserver((entries) => {
const e = entries[0];
// When sentinel is visible, we're near the top: no shadow.
header.dataset.elevated = e.isIntersecting ? 'false' : 'true';
}, { root: null, threshold: [0, 1] });
io.observe(sentinel);
})();
Significado de la salida: el estado de elevación ahora lo impulsa la intersección, no las heurísticas de scrollY. Decisión: prefiérelo si tienes banners superiores dinámicos, barras colapsables o un layout complicado donde “cima” no es solo scrollY==0.
Broma #2: Si adjuntas tres listeners de scroll, el navegador no te “multithreadea”, simplemente te juzga en silencio.
Accesibilidad y la regla de “no romper el botón atrás”
Ocultar un encabezado es una decisión de UX. Si lo implementas sin cuidado, se convierte en un defecto de accesibilidad.
Teclado y foco: nunca ocultes controles con foco
Si el encabezado contiene un input de búsqueda o enlaces de navegación, el usuario puede tabear hacia él. Si tu script oculta el encabezado mientras un control dentro tiene foco, has creado una interacción “ahora lo ves, ahora no” que es hostil para usuarios de teclado.
Solución: si header.contains(document.activeElement) es true, fuerza visible el encabezado. Es una condición pequeña y evita una clase de bug sorprendentemente desagradable.
cr0x@server:~$ rg "activeElement" -n sticky-header.js
Significado de la salida: sin resultados indica que no has añadido protección de foco. Decisión: añádela si el encabezado contiene elementos interactivos (casi siempre).
Lectores de pantalla: evita quitar contenido del árbol de accesibilidad
Deslizar el encabezado con transform lo mantiene en el DOM y en el árbol de accesibilidad. Eso normalmente está bien. No establezcas display: none como tu mecanismo de “oculto” a menos que estés dispuesto a manejar el foco, los estados aria y las consecuencias de reflow.
Si debes ocultarlo por completo, usa una gestión de foco cuidadosa y considera establecer inert (donde sea compatible) para evitar tabear en controles fuera de pantalla. Pero entonces estás construyendo un framework de UI. Intenta no hacerlo.
La reducción de movimiento no es opcional
Ya viste el CSS. Mantenlo. También considera omitir por completo el comportamiento de ocultar/mostrar basado en dirección bajo reducción de movimiento si el producto lo permite. Una UI que se desliza con el scroll puede parecer que la página está “viva”. Algunos usuarios no desean una página viva.
No rompas la restauración de scroll integrada del navegador
El navegador intenta restaurar la posición de scroll en la navegación atrás/adelante. Si tu encabezado cambia de altura o desencadena layout durante el paint inicial, puedes obtener comportamientos extraños de “restaurar al sitio equivocado y luego saltar”.
Mejores prácticas: mantén el layout del encabezado estable desde el primer frame. Evita cargar una webfont gigante tarde que cambie la altura del encabezado. Si no puedes evitarlo, establece alturas explícitas y usa estrategias de font-display que no refluyaan el encabezado.
Rendimiento: de dónde viene el jank (y cómo eliminarlo)
El rendimiento del desplazamiento es un presupuesto. El navegador quiere alcanzar ~60fps en hardware común. Eso te da aproximadamente 16ms por frame, y eso se comparte con todo lo demás: layout, pintura, composición, JS, imágenes, anuncios, analíticas, lo que sea.
Por qué las transformaciones suelen ganar
Una animación con transform: translateY() a menudo puede ser manejada por el hilo de compositor. Eso significa que puede seguir moviéndose incluso si el hilo principal está ocupado. “A menudo” implica trabajo en esa frase; aún necesitas evitar forzar layout y pinturas pesadas.
Las tres fuentes principales de jank para encabezados que se ocultan al desplazar
- Thrash de layout: alternar propiedades como
height,topo clases que refluyen la página en cada evento de scroll. - Sobrecarga del hilo principal: el manejador de scroll hace demasiado, o desencadena recálculos de estilo repetidos.
- Tormentas de pintura: sombras, desenfoques y fondos translúcidos que repintan grandes áreas durante transforms en GPUs de gama baja.
Haz las sombras condicionales, no constantes
Una sombra grande se ve bien. También puede ser cara, especialmente en móvil. Aplícala solo cuando estés fuera de la parte superior y considera sombras más simples para dispositivos de gama baja. El enfoque de atributo “elevated” te da un toggle limpio.
Usa listeners pasivos y requestAnimationFrame
Los listeners de scroll pasivos le dicen al navegador que no llamarás a preventDefault(), así que puede desplazar sin esperar tu JS. Batch con requestAnimationFrame evita trabajo redundante entre frames.
“La esperanza no es una estrategia.” —General H. Norman Schwarzkopf
Se aplica también a la fiabilidad de la UI. Si “esperas” que tu manejador de scroll esté bien porque es pequeño, enviarás una regresión cuando alguien añada llamadas de analítica o consultas DOM en el mismo bucle.
Tres micro-historias corporativas (las que enseñan)
Micro-historia 1: El incidente causado por una suposición errónea
Un equipo de dashboard interno desplegó un nuevo encabezado global con comportamiento “ocultar al desplazar”. Se suponía que ayudaría a los analistas a ver más filas en una tabla densa. La implementación parecía limpia: un listener de scroll comparaba window.scrollY con el último valor y alternaba display: none en el encabezado.
La suposición errónea fue sutil: “Ocultar el encabezado es lo mismo que traducirlo fuera de vista”. En su modelo mental, el encabezado era puramente visual. En el modelo del navegador, cambiar display afecta el layout, que afecta la altura del scroll, que cambia la posición de scroll, que dispara más eventos de scroll.
En páginas con tablas virtualizadas, la eliminación del encabezado cambió la altura disponible del viewport. La tabla recalculó el renderizado de filas. Eso disparó layout. El layout cambió la posición de scroll ligeramente. El manejador de scroll vio movimiento y volvió a alternar. El resultado no fue un bucle infinito completo, pero sí un parpadeo violento que disparó CPU y dejó la página rota.
Solo se reproducía en ciertas máquinas porque el rendimiento determinaba si la oscilación se amortiguaba o amplificaba. El informe del incidente terminó con la solución menos glamorosa de la historia: mantener el encabezado en el layout, ocultarlo con transform y añadir un umbral en píxeles para ignorar ruido. Nadie fue ascendido por ello, pero los gráficos dejaron de gritar.
Micro-historia 2: La optimización que salió mal
Un sitio de producto quería desplazamiento muy fluido en Android de gama baja. Alguien sugirió “acelerar todo por GPU” y añadió will-change: transform al encabezado, al hero, al rail de CTA y a algunos otros componentes. La idea: promover elementos a capas para evitar jank.
Durante unos días mejoró en un par de dispositivos de desarrollo. Luego el monitoreo real de usuarios mostró mayor uso de memoria y más eventos de “recarga de pestaña” en móvil. La promoción a capas aumentó la presión de memoria GPU, y en algunos dispositivos el navegador fue agresivo reclamando recursos.
El encabezado en sí estaba bien. El problema fue sistémico: will-change no es un hechizo mágico; es una pista que cuesta memoria. Demasiadas capas promovidas pueden hacer que el compositor thrashée o desencadene cargas de tiles. La “optimización” se convirtió en un golpe a la fiabilidad.
La solución fue usar will-change solo en el encabezado (y solo cuando fuera necesario), simplificar la sombra y quitarlo de todo lo demás. El desplazamiento volvió a ser aburrido, que es el mayor cumplido que una UI puede recibir en producción.
Micro-historia 3: La práctica aburrida pero correcta que salvó el día
Una gran aplicación empresarial tenía una regla: cualquier comportamiento global de UI debía desplegarse detrás de una feature flag, con un interruptor de apagado controlado por ops. No era sexy. Los ingenieros a veces ponían los ojos en blanco. Entonces llegó una actualización del navegador.
La actualización cambió algo del comportamiento de scroll en un subconjunto de dispositivos. El encabezado que se oculta al desplazar empezó a temblar, pero solo cuando un widget de terceros embebido estaba presente. El widget inyectaba un elemento fijo grande que alteró decisiones de composición. Los usuarios se quejaron de que el encabezado “vibra”.
Como la funcionalidad estaba detrás de una flag, el on-call la desactivó para los agentes de usuario afectados mientras el equipo investigaba. El sitio siguió siendo usable. Nadie tuvo que hotfixear a medianoche bajo presión. Al día siguiente, parchearon la lógica para evitar alternar mientras el widget animaba y ajustaron los umbrales para ese navegador.
La lección es aburrida y permanente: despliega comportamientos de UI con una forma de desactivarlos. No porque esperes fallos, sino porque la realidad es inventiva.
Tareas prácticas: comandos, salidas y decisiones
Pediste tareas reales, no vibes. Estas son las comprobaciones que ejecuto cuando un encabezado “ocultar al desplazar” se siente mal en producción. Son una mezcla de verificación server-side (¿desplegamos los assets correctos?), depuración cliente (¿estamos sirviendo demasiado?) y diagnóstico de rendimiento.
Tarea 1: Verificar que el CSS desplegado contiene reglas sticky + transform
cr0x@server:~$ grep -nE "position:\s*sticky|will-change:\s*transform|translateY" /var/www/app/static/ui.css | head
132:header.site-header { position: sticky;
139: will-change: transform;
151:header.site-header[data-hidden="true"] { transform: translateY(calc(-1 * var(--header-h)));
Significado de la salida: las propiedades clave existen en el artefacto desplegado. Decisión: si faltan, tu pipeline de build probablemente desplegó un bundle antiguo o un tema diferente; arregla el despliegue antes de depurar “rendimiento”.
Tarea 2: Confirmar que la altura del encabezado es constante en el CSS (sin altura animada)
cr0x@server:~$ grep -nE "header\.site-header|height:" -n /var/www/app/static/ui.css | sed -n '120,175p'
132:header.site-header {
145: height: var(--header-h);
151:header.site-header[data-hidden="true"] {
Significado de la salida: la altura está seteada una vez, no alternada. Decisión: si ves cambios de altura en distintos estados, espera shifts de layout y reflow; refactoriza a transforms.
Tarea 3: Validar que el bundle JS contiene el manejador de scroll mínimo
cr0x@server:~$ rg -n "requestAnimationFrame\\(update\\)|passive:\\s*true|data-hidden" /var/www/app/static/app.js | head
8432: requestAnimationFrame(update);
8440: window.addEventListener('scroll', onScroll, { passive: true });
8456: header.dataset.hidden = hidden ? 'true' : 'false';
Significado de la salida: las partes importantes están presentes. Decisión: si no ves listeners pasivos o rAF, probablemente haces demasiado trabajo por evento de scroll; corrige eso primero.
Tarea 4: Comprobar eficacia de gzip/brotli para JS/CSS (enviar menos importa)
cr0x@server:~$ curl -sI -H 'Accept-Encoding: br' http://localhost/static/app.js | grep -iE 'content-encoding|content-length|cache-control'
Content-Encoding: br
Content-Length: 182943
Cache-Control: public, max-age=31536000, immutable
Significado de la salida: brotli está habilitado; el tamaño del payload es visible; el cache es de larga duración. Decisión: si no hay compresión o cache, arregla eso antes de micro-optimizar las matemáticas del scroll.
Tarea 5: Confirmar tipos MIME correctos (evita comportamientos extraños del navegador y problemas de cache)
cr0x@server:~$ curl -sI http://localhost/static/ui.css | grep -iE 'content-type|cache-control'
Content-Type: text/css; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
Significado de la salida: tipo MIME correcto y cache. Decisión: si el MIME es incorrecto, algunos navegadores tratan los assets de forma distinta; arregla la configuración del servidor.
Tarea 6: Detectar listeners de scroll duplicados accidentalmente en el bundle
cr0x@server:~$ rg -n "addEventListener\\('scroll'" /var/www/app/static/app.js | head -n 20
8440: window.addEventListener('scroll', onScroll, { passive: true });
12110: window.addEventListener('scroll', trackScrollDepth, { passive: true });
17822: document.addEventListener('scroll', legacyScrollHandler);
Significado de la salida: existen múltiples listeners de scroll. Decisión: audítalos. Si ves un “legacyScrollHandler”, probablemente tengas comportamientos en competición y trabajo innecesario; elimina o blinda detrás de flags.
Tarea 7: Confirmar que la página no fuerza layout en el scroll (buscar código que dispare layout)
cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight|clientHeight" /var/www/app/static/app.js | head
5209: const h = header.offsetHeight;
10902: const rect = el.getBoundingClientRect();
Significado de la salida: estas llamadas pueden provocar layout si se mezclan con escrituras. Decisión: asegúrate de que estas lecturas no estén dentro del manejador de scroll o estén aisladas antes de escrituras; de lo contrario crearás layout síncrono forzado.
Tarea 8: Revisar logs de acceso de Nginx por churn de assets (¿los usuarios están re-descargando constantemente?)
cr0x@server:~$ sudo awk '$7 ~ /\/static\/(app\.js|ui\.css)/ {print $7, $9}' /var/log/nginx/access.log | tail -n 8
/static/ui.css 200
/static/app.js 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
/static/app.js 200
/static/ui.css 200
Significado de la salida: muchos 200s sugieren que el cache podría estar roto (deberían ser 304s o hits en CDN). Decisión: verifica headers de cache y configuración del CDN; descargas repetidas retrasan la interactividad y pueden empeorar el jank tras navegación.
Tarea 9: Comprobar tiempos de respuesta del servidor para el HTML (TTFB lento puede retrasar CSS/JS)
cr0x@server:~$ curl -o /dev/null -s -w "ttfb=%{time_starttransfer} total=%{time_total}\n" http://localhost/
ttfb=0.043 total=0.051
Significado de la salida: TTFB y total rápidos. Decisión: si TTFB es alto, tu “jank de encabezado” podría ser síntoma de CSS/JS que cargan tarde por un HTML lento; arregla backend o cache primero.
Tarea 10: Validar que no estás enviando scripts de terceros sin control
cr0x@server:~$ rg -n "googletagmanager|segment|hotjar|fullstory|datadogRum" /var/www/app/templates/index.html
42:<script>/* datadogRum init */</script>
Significado de la salida: runtime de terceros presente. Decisión: si el scroll empeora solo después de que las analíticas inicializan, quizá debas retrasar scripts no críticos, muestrear agresivamente o aislarlos del path de scroll.
Tarea 11: Comprobar señales de layout shift en logs del navegador capturados por herramientas (estilo Lighthouse CI)
cr0x@server:~$ jq '.audits["cumulative-layout-shift"].numericValue' ./lighthouse-report.json
0.19
Significado de la salida: CLS no trivial. Decisión: inspecciona si la altura del encabezado o el padding superior cambian después de la carga, y si fuentes o banners tardíos empujan contenido. Arregla CLS antes de pulir la animación de ocultado.
Tarea 12: Confirmar que el encabezado no es más alto en iOS por cálculo erróneo de safe-area
cr0x@server:~$ grep -n "safe-area-inset-top" -n /var/www/app/static/ui.css
88:header.site-header { padding-top: env(safe-area-inset-top); height: calc(var(--header-h) + env(safe-area-inset-top)); }
Significado de la salida: el área segura está manejada explícitamente. Decisión: si falta y tienes usuarios con notch en iOS, añádelo; la navegación recortada genera tickets de soporte reales.
Tarea 13: Validar que existe un interruptor de apagado por feature-flag para el comportamiento
cr0x@server:~$ rg -n "HIDE_HEADER_ON_SCROLL|featureFlag.*header" /var/www/app/static/app.js | head
902: if (!window.__FLAGS__?.HIDE_HEADER_ON_SCROLL) return;
Significado de la salida: el comportamiento puede desactivarse. Decisión: si no tienes esto, eliges depurar en producción mediante redeploy. Eso es un estilo de vida, no una decisión de ingeniería.
Tarea 14: Revisar logs de errores por excepciones JS que dejen el encabezado oculto
cr0x@server:~$ sudo journalctl -u nginx -n 50 --no-pager | tail -n 10
Dec 29 10:14:03 web nginx[2213]: 2025/12/29 10:14:03 [warn] 2213#2213: *8930 upstream response is buffered to a temporary file
Significado de la salida: esto es del lado servidor y no está directamente relacionado con JS, pero es un recordatorio de revisar la telemetría de errores cliente también. Decisión: si hay excepciones cliente alrededor del código de scroll, añade try/catch y falla abierto (encabezado visible).
Si te preguntas por qué un ingeniero de almacenamiento te dice que revises headers de cache: es porque latencia y tamaño de payload son experiencia de usuario, y la experiencia de usuario es producción.
Guía de diagnóstico rápido
Cuando el encabezado fijo se comporta mal, no “ajustes los umbrales” primero. Así pierdes una tarde. Diagnostica como un SRE: aísla, mide, reduce variables.
Primero: ¿es un cambio de diseño o jank de desplazamiento?
- Comprobar: ¿el contenido se mueve arriba/abajo cuando el encabezado se oculta/muestra?
- Interpretación: si sí, estás haciendo cambios de layout (height/display/margins) o cargando assets que cambian el tamaño del encabezado.
- Acción: haz la altura del encabezado constante; usa transform; fija tamaños explícitos para fuentes/iconos.
Segundo: ¿hay demasiados listeners de scroll o trabajo caro dentro de ellos?
- Comprobar: busca en el bundle
addEventListener('scroll', mide la cantidad, encuentra handlers legacy. - Interpretación: múltiples handlers suelen competir y disparar lecturas/escrituras de layout.
- Acción: consolida en un handler rAF-batch; mueve analíticas fuera del path de scroll.
Tercero: ¿el compositor está sufriendo (pinturas pesadas, sombras, fondos translúcidos)?
- Comprobar: ¿el stutter se correlaciona con contenido pesado o solo en dispositivos de gama baja?
- Interpretación: los grandes blur/shadow en elementos en movimiento pueden ser caros.
- Acción: simplifica la sombra; evita backdrop-filter; reduce capas alpha; no abuses de will-change.
Cuarto: ¿es una peculiaridad del viewport móvil (barra dinámica de Safari)?
- Comprobar: ¿ocurre solo en iOS Safari, especialmente cuando la barra de direcciones colapsa/expande?
- Interpretación: la detección por scrollY/top puede ser ruidosa.
- Acción: usa un IntersectionObserver centinela para el estado “en la cima”; añade umbrales; evita depender de scrollY==0 exacto.
Quinto: ¿es un bug de interacción (foco, teclado, objetivos táctiles)?
- Comprobar: tabea hacia elementos del encabezado; ¿desaparece mientras están enfocados?
- Interpretación: estás escondiendo sin considerar el estado de foco/interacción.
- Acción: fuerza visible cuando hay foco; considera un temporizador de bloqueo corto tras interacciones.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: el encabezado parpadea rápidamente en trackpads o pantallas táctiles
Causa raíz: la detección de dirección no tiene deadzone; pequeños deltas alternan el estado constantemente.
Solución: añade umbrales separados para ocultar/mostrar; conserva la última dirección hasta que se supere el umbral; actualiza estado en rAF.
2) Síntoma: el contenido “salta” cuando el encabezado se oculta o muestra
Causa raíz: ocultar cambia el layout (height/display/margins) o fuentes/iconos que cargan tarde cambian el tamaño del encabezado.
Solución: mantén la altura del encabezado constante; usa transform; establece alturas explícitas; evita reflow tardío en el encabezado.
3) Síntoma: el encabezado cubre los encabezados de sección después de clicar enlaces del TOC
Causa raíz: falta manejo de offset de ancla.
Solución: usa scroll-margin-top en los encabezados o :target con la altura del encabezado.
4) Síntoma: el encabezado queda oculto tras navegar hacia atrás
Causa raíz: estado restaurado incorrectamente; script se inicializa antes de que exista el encabezado; o excepción JS detiene las actualizaciones.
Solución: ejecuta una update() inicial; falla abierto (visible) en errores; asegúrate de que el script se ejecute después de DOM ready o usa defer.
5) Síntoma: el desplazamiento es fluido hasta que cargan las analíticas, luego se vuelve entrecortado
Causa raíz: contención del hilo principal; analíticas hacen trabajo durante el scroll o disparan lecturas de layout.
Solución: elimina hooks de analítica del path de scroll; muestrea eventos; usa IntersectionObserver para profundidad de scroll; difiere scripts no críticos.
6) Síntoma: funciona en Chrome de escritorio, pero tartamudea en iOS Safari
Causa raíz: cambios dinámicos de la barra de herramientas del viewport; overscroll; diferencias de composición.
Solución: usa centinela para el estado top; evita comparaciones exactas con scrollY==0; mantén animaciones solo por transform; respeta áreas seguras.
7) Síntoma: usuarios de teclado pierden el elemento con foco
Causa raíz: el encabezado se oculta mientras está enfocado; o el estado oculto elimina elementos del layout.
Solución: no ocultes si header.contains(document.activeElement); evita display: none para el comportamiento de ocultado.
8) Síntoma: el encabezado se siente “lento” (aparece tarde al desplazar hacia arriba)
Causa raíz: umbrales demasiado grandes; el manejador de scroll se ejecuta de forma intermitente; o trabajo pesado demora el rAF.
Solución: mantén el umbral de mostrar más pequeño que el de ocultar; reduce trabajo en el handler; elimina lecturas DOM costosas.
9) Síntoma: la sombra del encabezado repinta toda la página durante el scroll
Causa raíz: sombras pesadas/backdrop filters en un elemento en movimiento provocan pinturas costosas.
Solución: simplifica la sombra; evita filtros de desenfoque; alterna la sombra solo cuando sea necesario; considera un borde en lugar de sombra.
10) Síntoma: el encabezado se solapa con el notch / barra de estado en iPhones
Causa raíz: no se manejó el safe area.
Solución: añade padding-top: env(safe-area-inset-top) y ajusta la altura del encabezado en consecuencia.
Listas de verificación / plan paso a paso
Plan de implementación paso a paso (hazlo en este orden)
- Despliega primero un encabezado sticky aburrido.
position: sticky; top: 0;altura fija, sin comportamiento de ocultado. - Añade offsets de ancla. Usa
:target { scroll-margin-top: ... }o aplícalo a los encabezados. - Añade solo el estado “elevated”. Sombra/borde después de salir de la cima, impulsado por un centinela o un pequeño umbral de scrollY.
- Añade ocultado/mostrado solo con transform. Sin cambios de altura. Sin toggles de display.
- Añade detección de dirección con umbrales. Umbral de ocultar mayor que el de mostrar.
- Protege estados de interacción. No ocultes mientras hay foco o durante interacción activa pointer si es necesario.
- Respeta la reducción de movimiento. Deshabilita transiciones (y posiblemente el comportamiento) bajo
prefers-reduced-motion. - Feature-flag it. Añade interruptor de apagado; por defecto activado solo después de pruebas.
- Mide. Monitorea CLS e INP; prueba el rendimiento del scroll en dispositivos representativos.
Checklist de release (con sabor SRE)
- Altura del encabezado constante entre estados (inspecciona estilos computados).
- El ocultado usa
transform, no propiedades de layout. - Solo un listener de scroll para el comportamiento; es pasivo y rAF-batcheado.
- Las anclas no quedan bajo el encabezado.
- Área segura manejada para iOS.
- Reducción de movimiento respetada.
- Falla abierta: si JS falla, el encabezado permanece visible y usable.
- Feature flag + interruptor de apagado validados en configuración de producción.
- Dashboards RUM vigilados por regresiones de CLS/INP tras el despliegue.
Checklist de afinado (umbrales y sensación)
- Empieza con umbral de ocultar ~10–16px y umbral de mostrar ~4–8px.
- Asegura que “en la cima” fuerce visible y no elevado.
- Prefiere “mostrar rápido, ocultar con reticencia.” Los usuarios perdonan un encabezado que aparece; odian uno que los bloquea.
- Si la página tiene scroll infinito, sé especialmente conservador con el ocultado. El usuario ya está haciendo mucho scroll; no añadas sorpresas.
Preguntas frecuentes
1) ¿Se puede hacer solo con CSS?
No por completo. CSS puede hacer que algo sea sticky y puede animar un estado de ocultar/mostrar una vez que ese estado está expresado. Pero la “dirección de scroll” no es una entrada de CSS hoy. Si necesitas ocultar-al-bajar/mostrar-al-subir, necesitas JS o una característica de plataforma que aún no existe.
2) ¿Debería usar position: fixed en lugar de sticky?
Usa sticky a menos que tengas una razón fuerte. Sticky participa en el flujo normal, lo que evita algunos casos límite de layout. Fixed está bien, pero es más fácil crear solapamientos accidentalmente y tendrás que gestionar paddings/márgenes superiores para evitar que el contenido quede oculto.
3) ¿Por qué no alternar display: none al ocultar?
Porque cambia el layout. Eso puede crear CLS, pelear con el anclaje de scroll y causar reflow costoso. El ocultado por transform mantiene el layout estable y suele scrollar mejor.
4) ¿Siempre es bueno will-change: transform?
No. Consume recursos al incentivar la promoción a capa. Úsalo con moderación y preferiblemente en el único elemento que realmente animas (el encabezado). No lo pongas por toda la UI.
5) ¿Qué pasa con backdrop-filter para un encabezado tipo vidrio esmerilado?
Puede verse genial y rendir terriblemente, especialmente durante movimiento. Si debes usarlo, pruébalo en dispositivos de gama baja y considera desactivarlo durante transiciones de ocultado/mostrar o detrás de una verificación de capacidad.
6) ¿Cómo evito que el encabezado se oculte cuando el usuario desplaza dentro de un contenedor anidado?
Decide qué contenedor de scroll dicta el comportamiento. Si tu contenido se desplaza dentro de un div, usa los eventos de scroll y mediciones de ese elemento en lugar de window. Mezclarlos es una fuente clásica de “se oculta al azar”.
7) ¿Por qué se comporta distinto en iOS Safari?
Barras de herramientas dinámicas, overscroll y diferencias de viewport. Evita lógica que dependa de posiciones de píxel exactas en la cima. Usa un centinela para la detección top y añade umbrales para que pequeñas oscilaciones no alternen el estado.
8) ¿Cómo evito que se oculte mientras los usuarios interactúan con el encabezado?
Añade protecciones: si el encabezado contiene el elemento activo, mantenlo visible. Opcionalmente bloquea la visibilidad por un corto tiempo tras pointerdown/touchstart en la región del encabezado. Manténlo simple y prueba con teclado y lectores de pantalla.
9) ¿Cuál es el modo de fallo más seguro?
El encabezado permanece visible. Si el JS no carga, lanza excepción o es bloqueado, el usuario debe seguir teniendo navegación. Por eso importa el enfoque CSS primero.
10) ¿Cómo mido el éxito más allá de “se siente suave”?
Monitorea CLS e INP, además de señales de engagement como el uso de navegación (con cuidado, sin loggear en cada scroll). Si CLS sube tras el despliegue, asume que el encabezado contribuyó hasta que se demuestre lo contrario.
Conclusión: próximos pasos ejecutables
Construye el encabezado fijo como construyes un servicio fiable: empieza con una base estable, añade comportamiento controlado detrás de una pequeña máquina de estados y mantén un interruptor de apagado al alcance. CSS te da estabilidad. JS mínimo te da lo único que CSS no puede: la dirección.
Próximos pasos:
- Audita tu encabezado actual: si cambia altura o display durante el scroll, arregla eso primero.
- Añade
:targetscroll margin (o márgenes específicos en encabezados) para evitar solapamiento de anclas. - Implementa detección de dirección batcheada con rAF y listeners pasivos con umbrales; mantenlo por debajo de 50 líneas.
- Añade un centinela para la elevación en la parte superior si las peculiaridades móviles te lo requieren.
- Despliega detrás de una feature flag, vigila CLS/INP y prepárate para apagar sin redeploy.
Cuando funciona, nadie lo nota. Ese es el punto. Un encabezado es una herramienta, no una pieza de arte performativa.