name: immersive-scroll
description: Efecto web inmersivo "ir hacia delante" — fondo POV (secuencia de fotogramas en canvas) que avanza con el scroll + escenas centradas que se cruzan con cross-fade. Úsalo al crear o tocar experiencias scroll-driven de este tipo (la plantilla expedicion/Bravío en apps/demos, o nuevas), o al replicar los prototipos de _immersive-proto. Cubre el motor, el modelo desktop vs móvil (paneo) y los GOTCHAS caros aprendidos.
Immersive scroll — efecto "ir hacia delante"
Experiencia donde el contenido NO scrollea como un documento: un fondo POV (metraje de senderismo como secuencia de fotogramas dibujada en <canvas>) avanza con el scroll, y encima aparecen escenas centradas que se cruzan con cross-fade. Sensación de avanzar por un camino. Degrada con dignidad a scroll normal si el dispositivo no puede.
Dónde vive
- Prototipos de referencia (estáticos, fuera del deploy):
docs/immersive-proto/— solo 3 técnicas distintas:index.html(fly-through procedural en canvas),v3.html(parallax fotográfico),v6.html(Gaussian Splatting de un lugar real, vía Luma). El prototipo del efecto de producción (POV + frames, antiguov7) ya NO se guarda: vive en la demo Bravío (apps/demos). Se descartaron v2/v4/v4b/v5/v7 + las secuencias de frames (~12 MB). Antes estaban enapps/es/public/_immersive-proto/(Next los servía en prod) → movidos. Verdocs/immersive-proto/README.md. - Catálogo de OTROS efectos de motion/scroll (candidatos a futuras demos o a
apps/es: revelado con cursor, personaje por scroll, stacking cards, marquee divergente, parallax de nubes, coverflow, coreografía de secciones fijadas):docs/motion-effects-catalog.md— con técnica (CSS scroll-timeline vs GSAP vs canvas), coste, móvil y dónde encaja cada uno. - Producción: plantilla
expedicion(marca demo Bravío, slugbravio) enapps/demos/components/templates/expedicion/. Motor:ExpedicionImmersiveStage.tsx(+.module.css). Es una plantilla deapps/demos→ ver también/demos-template-system.
El motor (cómo funciona)
- Capas FIJAS (
position:fixed; inset:0):poster(fallback) →canvas(metraje) →grade(velo oscuro para legibilidad) →frame(marquito: líneas + esquinas) →scenes(las escenas). Cubren siempre el viewport. spacer: un div alto (N*120vhaprox.) que da la DISTANCIA de scroll. El progreso sale de él:p = clamp(-spacer.getBoundingClientRect().top / (spacer.offsetHeight - innerHeight), 0, 1).- Un solo
rAFon-demand: despierta con el scroll (wake()), duerme al converger. Dibuja el fotograma≈ p*(FRAME_COUNT-1)en el canvas (cover) y fija opacidad/transform de cada escena consceneVis(p, idx)(meseta + fundido en los bordes de su tramo). - Canvas image-sequence, NO vídeo: fotogramas WebP (~29 KB c/u, ~93 frames ≈ 2.8 MB) precargados; se scrubean con el scroll. Un
<video>da saltos al hacer seek; los fotogramas no. - Mejora progresiva: SSR/fallback =
data-mode="normal"(scroll normal, mismo DOM). El inmersivo se activa SOLO en cliente si hay capacidad (noprefers-reduced-motion, nosave-data, ventana no demasiado baja) y tras precargar unos fotogramas.modeporuseState→ sin hydration mismatch. - a11y: escenas inactivas
inert+visibility:hidden; la activa quitainert. Skip-link, focus rings de doble anillo sobre el metraje. - Parallax de cursor (solo ratón fino): un
pointermovemueve el "punto de vista" — las ramas de primer plano se desplazan MÁS (PAR_BRANCH) que el contenido de las escenas (PAR_SCENE), opuesto al cursor → profundidad. Suavizado con lerp en el loop; el desplazamiento va ANTES delscalede la rama (sigue creciendo desde su borde, no se despega). El loop no se duerme hasta que el parallax converge.
Dos modelos de tamaño — la clave, POR ORIENTACIÓN (no por ancho)
El inmersivo se activa en todas las orientaciones (el user lo quiere "en todo"). El modelo se elige por ORIENTACIÓN, no por ancho de pantalla:
isMobile = portrait && ((pointer:coarse) || (max-width:900px)).
- PORTRAIT (vertical) → modelo PANEO (
isMobile=true, clasehtml.exp-mobile): MISMO cross-fade, pero meter 5 tarjetas + formulario en una pantalla legible es imposible → cada sección recibe distancia de scroll proporcional a su alto y PANEA verticalmente (la<section>se traslada dentro del marco fijo), revelando su contenido sin recortes; el cross-fade se reserva para el paso ENTRE secciones. Reparto conbounds[]/panDist[]ponderados por alto (layout()). (Se probó un modo "flow" = fondo avanza + scroll normal; RECHAZADO.) - LANDSCAPE + DESKTOP → modelo ESCALA (
isMobile=false): una sección = UNA pantalla;fitScenes()solo REDUCE una escena si a 100% no cabe; nunca agranda. Por qué landscape va aquí y no en paneo: el paneo en una ventana muy baja (móvil horizontal ~390px de alto) hace el spacer gigantesco → scroll lentísimo + "teletransportes" al cambiar de dirección. El modelo de escala da scroll proporcional y limpio. - Recargar al ROTAR: el modelo se decide al montar; un
matchMedia("(orientation: portrait)").onchange → location.reload()reinicia limpio al girar (arranca arriba porscrollRestoration:manual). - Alto interior del marco (sizing y paneo):
innerH − (nav.offsetHeight+10) − inset − margin.offsetHeightdel nav ignora barras de preview (franja "DEMO").
Móvil HORIZONTAL: reflows de contenido (el modelo escala lo aplastaría)
En un móvil en horizontal el alto útil del marco es ~270–290px. El modelo de escala mete las secciones altas a scale ~0.2–0.3 → texto de 3px, ilegible (medido: actividades 0.29, contacto 0.21). NO caer a scroll normal (el user quiere el inmersivo); en su lugar reflow del contenido a horizontal para que quepa a scale ~0.7–1 legible:
- Patrón:
@media (orientation: landscape) and (max-height: 560px) { :global(html.exp-immersive) .grid { … } }. Las altas pasan a UNA fila (actividades 5-col, guías/opiniones 3-col), contacto a 2-col (info | form), experiencia a bullets 2-col; se ocultan leads/notas redundantes y se acotan descripciones con-webkit-line-clamp. Resultado medido: todas a 0.63–1.15. - Scopear bajo
:global(html.exp-immersive)es OBLIGATORIO por DOS razones: (a) especificidad — las reglas de compactadohtml.exp-immersive .x(0,2,1) ganan a un.xpelado en media query (0,1,0), así que el reflow debe igualar el scope o no aplica; (b) solo en inmersivo — en el fallback de scroll normal la página es larga y NO debe reflowear. max-height:560separa móvil-landscape (360–430px) de **tablet-landscape (768px, que NO debe reflowear**: ya cabe a 0.8+ con su layout original). Verificado que portrait (paneo) y tablet-landscape (layout original) no se tocan.- Para saber qué escenas necesitan reflow: medir
section.scrollHeightnatural a ese viewport yscale = availH / natural; tratar las < ~0.7. Elnavtambién se baja a 52px en landscape para recuperar alto (frameInteriorHleenav.offsetHeight).
GOTCHAS (oro — esto es lo que cuesta descubrir)
sticky/overflowcapturan la rueda. Un contenedorposition:stickyo conoverflowen el stage ATRAPA elwheely la ventana no scrollea. Por eso el modelo es capas FIJAS + spacer alto. No lo cambies a sticky.scroll-behavior:smoothglobal rompe el scroll por JS. Si el scroll animado hacewindow.scrollTo(0,y)60×/s y el<html>tienescroll-behavior:smooth, la forma de 2 args respeta el smooth y CADA frame reinicia la animación nativa → el scroll se ATASCA (síntoma típico: el click del nav "no hace nada"). Fix: al activar,document.documentElement.style.scrollBehavior="auto"INLINE (gana a la hoja) + limpiar al desmontar.- Medir alturas DESPUÉS de fuentes/imágenes o el tamaño baila entre recargas. Si mides con la fuente de sistema y luego carga la webfont (Oswald condensada mide distinto), o antes de que las imágenes reflowen, la escala/paneo sale distinto según el orden de carga (caché) → "a veces grande, a veces pequeño sin tocar código". Fix: gatear la activación a
document.fonts.ready+ unResizeObserversobre cada<section>que re-mida (debounced a rAF). Converge siempre al tamaño correcto. - La línea del marco debe seguir al nav, pero el contenido se centra en el marco.
--frame-top=nav.getBoundingClientRect().bottom + 10(DINÁMICO en el loop) → el marco se adapta: más pequeño con barra de preview, más grande al hacer scroll → la línea nunca cruza el texto del nav. La escena se centra usando--frame-top(padding) → aire igual arriba/abajo dentro del marco. - PNG de primer plano (ramas/parallax) que se cortan. Si la caja del PNG es estrecha (p.ej. 46% ancho), el
backgroundse recorta con una línea visible dentro del viewport. Fix: que el PNG llene la dimensión de su borde (background-size: auto 100%para izq/der,100% autopara arr/abj) en una caja MUY grande en la otra dimensión (≈240%) → lo que sobra sale FUERA de pantalla, sin línea de corte. Anclar al borde +transform-originahí (crece desde el borde al escalar = parallax). - Arranque rápido + sin "salto". Activar con POCOS fotogramas (≈10; el póster cubre lo que falta, el resto carga en 2º plano) en vez de esperar a los 93. Fundido de entrada CSS (
@keyframessobre canvas/frame/scenes, SINforwardspara no pisar elopacity: var(--stage-fade)del fundido de footer). - a11y en scroll-jacking. (a) SIEMPRE una escena no-inert (la más visible, sin umbral; si exiges
vis>0.5quedan TODAS inert a mitad de cross-fade y el foco cae al body). (b) NUNCA inertar la escena que contienedocument.activeElement(al enfocar un control, elscrollIntoViewdespierta el rAF y se perdería el foco). (c) Los CTAs conbox-shadowpropia PISAN el:focus-visibleglobal → añadir:focus-visible { box-shadow: var(--sh-focus) }en cada uno (mayor especificidad). (d) Verifica el foco con Tab real (no.focus(), que no dispara:focus-visible). - Fundido de salida hacia el footer. Al final, toda la capa inmersiva sube y se desvanece (
--stage-shift+--stage-fade) para que el footer (bloque normal, fuera del stage, z-index por encima) suba sobre el fondo oscuro como un pie tradicional.
Pruebas — no dispares el bucle de HMR
El MCP de Playwright escribe logs en .playwright-mcp/ (raíz del proyecto) y las capturas, si caen en la raíz, las vigila Next (HMR) → reconstruye en bucle y resetea el scroll/estado (tests eternos). Escribe capturas SIEMPRE a c:/temp, minimiza el nº de llamadas, y usa aserciones atómicas (scroll + lectura en un solo evaluate). .playwright-mcp/ y *.jpeg están en .gitignore.
Reutilizar el efecto en otra plantilla/sitio
El motor está acoplado a expedicion. Para reusar: copia ExpedicionImmersiveStage.tsx+.module.css y los tokens, mete tu secuencia de fotogramas en public/.../frames/, y pásale como children las escenas (cada hijo = una escena; en móvil las altas panean solas). Mantén los gotchas 1–8.