Saltar al contenido principal

Pistas horizontales y galerías

Tres recetas que convierten el scroll vertical en movimiento horizontal dentro de una escena anclada: una pista de tarjetas que se desliza de lado, una galería que lleva cada foto al foco por turnos, y un "dial" circular de pasos numerados.

Míralo en acción: Horizontal scroll en fancyanimations.com.

Pista horizontal anclada​

Mientras el escenario está anclado, una fila de tarjetas se desliza de derecha a izquierda y se detiene exactamente cuando la última tarjeta llega al borde derecho. La escena mide exactamente lo alto que la pista necesita: las tarjetas se mueven tanto como la página hace scroll, así que 3 tarjetas forman una escena corta y 12 tarjetas una larga — nunca demasiado lenta ni demasiado rápida, sea cual sea la cantidad.

Estructura:

section .ht reinicio de overflow-x, padding 0, container-type: inline-size
└─ div .ht__scene una fila Custom sin Start/End (se mide a sí misma)
└─ div .ht__stage sticky, overflow hidden
├─ div .ht__track sin ajustes de Fancy — lee el progreso de la escena por herencia
│ ├─ div .ht__card
│ └─ …
└─ div .ht__bar barra de progreso opcional
└─ div .ht__fill

CSS:

/* Dos longitudes que el CSS debe dividir: registradas, para que el navegador las resuelva a px
(atan2() acepta una longitud por argumento, como 100vh o 900px, pero no una expresión que
mezcle unidades, como n × min(450px, 80vw) + 2rem − 100cqi). */
@property --ht-travel { syntax: '<length>'; inherits: true; initial-value: 0px; }
@property --ht-run { syntax: '<length>'; inherits: true; initial-value: 0px; }

.ht { padding: 0; overflow-x: initial; container-type: inline-size; }
.ht__scene {
position: relative;
width: 100%;
--ht-n: 6; /* número de tarjetas: ajústalo para que coincida con el total real */
--ht-card: min(450px, 80vw); /* ancho de tarjeta */
--ht-gap: 2rem;
--ht-pad: 1.5rem; /* padding lateral del escenario */
--ht-speed: 1; /* 1 = las tarjetas se mueven tan rápido como la página; 1.5 = más lento */
/* cuánto recorre la pista: su ancho total menos el ancho del escenario */
--ht-travel: max(0px, var(--ht-n) * var(--ht-card) + (var(--ht-n) - 1) * var(--ht-gap) + 2 * var(--ht-pad) - 100cqi);
--ht-h: calc(var(--fa-scroll--custom) * 0vh + 100vh + var(--ht-travel) * var(--ht-speed));
height: var(--ht-h, auto);
}
.ht__stage {
position: sticky; top: 0; min-height: 100vh; overflow: hidden;
display: flex; flex-direction: column; justify-content: center; gap: 2rem;
padding-inline: var(--ht-pad);
/* cuánto ha avanzado la página dentro del tramo anclado */
--ht-run: calc(var(--fa-scroll--custom) * (var(--ht-h) + 100vh) - 100vh);
}
.ht__track {
display: flex; gap: var(--ht-gap);
width: max-content; max-width: max-content;
transform: translateX(calc(-1 * clamp(0px, var(--ht-run) / var(--ht-speed), var(--ht-travel))));
}
.ht__card { width: var(--ht-card); flex-shrink: 0; }
.ht__bar { height: 3px; width: min(300px, 60%); align-self: center; background: rgb(0 0 0 / 15%); overflow: hidden; }
/* progreso 0 → 1: tan(atan2(a, b)) es a / b para dos longitudes */
.ht__fill { height: 100%; background: currentColor; transform-origin: left; transform: scaleX(clamp(0, tan(atan2(var(--ht-run), var(--ht-travel) * var(--ht-speed))), 1)); }
  • La fila Custom de la escena no tiene ventana, así que --fa-scroll--custom es solo el progreso bruto de la escena; el escenario y todo lo que contiene lo leen por herencia — solo la escena necesita un ajuste de Fancy.
  • La pista se mueve exactamente tanto como la página ha hecho scroll dentro del tramo anclado, hasta el recorrido total — una relación precisa 1:1 con el scroll; la barra de progreso divide ese mismo valor entre el mismo recorrido, así que nunca pueden desincronizarse entre sí.
  • Mantén ambas reglas @property — sin ellas la barra se queda vacía, aunque la propia pista sigue funcionando.
  • 100cqi es el ancho de la sección sin incluir la barra de scroll (de ahí el container-type en la sección). Sin soporte de unidades de container query, usa 100vw en su lugar — entonces la última tarjeta se detiene algo así como el ancho de una barra de scroll más allá del borde.
  • Sin la animación (previsualización del constructor desactivada, sin JavaScript), --ht-h y la transformación se vuelven ambas inválidas, así que la escena toma su altura natural y muestra las primeras tarjetas como una fila normal.

Valores que puedes cambiar: --ht-n (actualízalo cada vez que se añadan o quiten tarjetas), --ht-card/--ht-gap/--ht-pad (mantenlos iguales a lo que usan los elementos reales — el recorrido se calcula a partir de ellos), --ht-speed (1 se siente como la propia página; 1.2–1.5 le da a cada tarjeta más tiempo en pantalla; por debajo de 1 la pista avanza más rápido que el scroll). Con muy pocas tarjetas el recorrido puede acabar en 0 — la escena entonces es solo una pantalla y nada se mueve, lo cual es correcto, no un error. Suavizado del scroll alrededor de 0.3s en la escena oculta el tirón que una rueda de ratón perdiendo velocidad mostraría de otro modo.

  • Escena: una fila Custom, suavizado del scroll ~0.3s, más las dos reglas @property (fuera del bloque %root% {} del elemento) seguidas de las reglas .ht__scene, con --ht-n ajustado al total real.
  • Escenario, pista, tarjetas, barra, fill: su CSS en el CSS personalizado propio de cada elemento. Ninguno de ellos necesita sus propios ajustes de Fancy — "Get parent" o una fila en la pista reemplazaría el progreso heredado por otra cosa.
  • Ajusta un --ht-card distinto por breakpoint en el CSS personalizado de la escena, no mediante el propio control de ancho de la tarjeta, o el recorrido no coincidirá con la realidad.

Problemas comunes:

  • La pista se mueve demasiado lento o demasiado rápido. La altura de la escena no se deriva realmente de la pista — una altura fija, o --ht-n/--ht-card que no coinciden con las tarjetas reales.
  • La última tarjeta se queda corta del borde, o lo sobrepasa. --ht-card, --ht-gap o --ht-pad difieren de lo que usan realmente los elementos.
  • La barra de progreso se queda vacía o llena mientras la pista claramente se mueve. Faltan las dos reglas @property, así que atan2() recibe unidades incompatibles y toda la declaración queda inválida.
  • Nada se mueve, o la pista salta de un lado a otro. La pista o el escenario tienen su propia fila de Fancy, o "Get parent" — solo deberían heredar el progreso de la escena.
  • Aparece una barra de scroll horizontal en la página. Falta overflow: hidden en el escenario, o el reinicio de overflow-x en la sección (un problema específico de Safari — ver Escenas ancladas).

Galería de enfoque​

Mientras el escenario está anclado, las fotos pasan por el centro de la pantalla una a una. La que está en foco se muestra a tamaño completo con su leyenda; las demás se encogen y se oscurecen, y cada imagen se desplaza suavemente dentro de su propio marco al pasar. Una sola fila de scroll impulsa toda la pista; cada elemento calcula su propia fase a partir de su posición y el total, así que añadir una foto no requiere reajustar nada.

Estructura (una escena anclada de 300vh, anclada 25–75%):

div .pin__stage variables de personalización (abajo)
└─ div .gallery__track "Get parent" + Custom 25–75 (+ suavizado del scroll ~0.3s)
└─ div .gallery__item ×n de 2 a 12 fotos
├─ div .gallery__frame > img .gallery__image
└─ div .gallery__caption

CSS:

.pin__stage { --gl-w: min(40vw, 70vh); --gl-o: .15; --gl-shrink: .15; --gl-dim: .5; }
.gallery__track {
display: flex; flex-direction: row; align-items: center; gap: 1.5rem;
width: max-content; max-width: max-content; position: relative;
padding-inline: calc((100% - var(--gl-w)) / 2);
--gl-p: calc(var(--fa-scroll--custom, 0) * (var(--gl-n, 2) - 1));
left: calc(var(--gl-s) / max(var(--gl-n, 2) - 1, 1) * 100%);
transform: translateX(calc(var(--gl-s) / max(var(--gl-n, 2) - 1, 1) * -100%));
}
:where(.gallery__track) { --gl-s: var(--gl-p); }
@supports (width: round(down, 1.5px, 1px)) and selector(:has(*)) {
.gallery__track {
--gl-k: round(down, var(--gl-p), 1);
--gl-f: calc(var(--gl-p) - var(--gl-k));
--gl-s: calc(var(--gl-k) + .5 * var(--gl-f) + .5 * var(--gl-f) * var(--gl-f) * (3 - 2 * var(--gl-f)));
}
}
.gallery__track:has(> :nth-child(2):last-child) { --gl-n: 2; }
.gallery__track:has(> :nth-child(3):last-child) { --gl-n: 3; }
/* … una regla por cantidad, hasta 12 */
.gallery__item {
--gl-i: 1; --gl-on: calc(var(--fa-scroll--custom) * 0 + 1);
width: var(--gl-w); min-width: var(--gl-w);
display: flex; flex-direction: column; gap: .75rem;
transform: scale(calc(1 - var(--gl-ad) * var(--gl-shrink)));
}
:where(.gallery__item) {
--gl-d: calc(var(--gl-s, 0) - (var(--gl-i) - 1));
--gl-ad: min(max(var(--gl-d), -1 * var(--gl-d)), 1);
}
.gallery__item:nth-child(2) { --gl-i: 2; }
.gallery__item:nth-child(3) { --gl-i: 3; }
/* … hasta 12 */
@supports not selector(:has(*)) { .gallery__item { --gl-d: 0; --gl-ad: 0; } }
.gallery__frame { position: relative; overflow: hidden; aspect-ratio: 4 / 3; opacity: calc(1 - var(--gl-ad) * var(--gl-dim)); }
.gallery__image {
position: absolute; top: 0; height: 100%; object-fit: cover; border-radius: 0;
left: calc(var(--gl-o) * -100%); width: calc(100% * (1 + 2 * var(--gl-o))); max-width: none;
transform: translateX(calc(clamp(-1, var(--gl-d), 1) * var(--gl-o) / (1 + 2 * var(--gl-o)) * 100%));
}
.gallery__caption { text-align: center; opacity: calc(1 - var(--gl-on, 0) * min(var(--gl-ad) * 2, 1)); }
  • --gl-s es la posición de la pista medida en elementos enteros (0 = primer elemento centrado). Un padding igual a media pantalla menos la mitad del ancho de un elemento centra el elemento i exactamente en s = i - 1.
  • Imán suave: la pista reduce a la mitad su velocidad justo en cada foto, pero nunca llega a detenerse del todo — cualquier parada real se siente como un salto con una rueda de ratón (la pista se detiene mientras la mano sigue girando, y luego de golpe se pone al día).
  • Contador, estilo "04 / 07", para cualquier cantidad de fotos: dado que cada elemento ya conoce su propia posición y el total mediante "Index children", su leyenda puede imprimirlos directamente:
.gallery__caption { counter-reset: gl-i calc(var(--fa-i, 0) + 1) gl-n var(--fa-n, 1); }
.gallery__caption::before { content: counter(gl-i, decimal-leading-zero) " / " counter(gl-n, decimal-leading-zero) " "; }

Esta alternativa necesita "Index children" en la pista en lugar de las listas de :has()/:nth-child de arriba — sustituye var(--gl-n, 2) por var(--fa-n, 2) y (var(--gl-i) - 1) por var(--fa-i, 0). Ambas variables solo existen una vez que JavaScript se ha ejecutado realmente, lo cual no es problema aquí, ya que sin la animación la pista simplemente descansa en la primera foto de todos modos.

Valores que puedes cambiar: --gl-w (ancho del elemento, ya limitado por la altura de pantalla para que quepa en pantallas bajas — dale uno más ancho a los móviles), --gl-o (desbordamiento de la imagen, es decir, la intensidad del parallax), --gl-shrink/--gl-dim (cuánto se encogen y oscurecen las fotos fuera de foco), y la altura de la escena (más alta = más lenta, lo cual ayuda con muchas fotos).

  • Pista: "Get parent", suavizado del scroll ~0.3s (requiere 3.1.1 — omítelo en sitios más antiguos), una fila Custom, Start 25, End 75.
  • Ancho de la pista en móvil: sobrescribe --gl-w en el CSS personalizado del breakpoint móvil de la clase del escenario.

Problemas comunes:

  • La pista se detiene sin una posición concreta en algunos navegadores, o nunca se mueve. Un var(--gl-s, respaldo) no puede rescatar un round() no soportado — toda la propiedad que lo usa se vuelve inválida. Mantén los valores por defecto simples fuera de @supports, exactamente como se muestra arriba.
  • Las fotos saltan bruscamente, o se nota el cambio entre elementos. Una parada brusca, o un descanso largo, en cada foto en lugar de la desaceleración de imán suave.
  • Tirones con la rueda del ratón. La pista se mueve varias veces más rápido que la página — añade suavizado del scroll ~0.3s en la pista.
  • La imagen parece encogerse dentro de su propio marco. El tema limita todos los elementos img a max-width: 100% — sobrescríbelo con max-width: none en la imagen.

Dial: pasos sobre un círculo giratorio​

Los pasos de un proceso se sitúan como marcadores numerados alrededor de un círculo. El scroll hace girar el círculo para que cada paso, por turnos, llegue al frente junto a su texto; un anillo se va llenando por detrás desde un punto de partida. En móviles, el dial se mantiene animado, apilado encima del texto en lugar de al lado.

Estructura:

section .dial-section reinicio de overflow-x, padding 0
└─ div .dial__scene fila Custom, sin Start/End; altura de escena según "Altura de escena" de arriba
└─ div .dial__stage sticky (ver Escenas ancladas)
└─ div .dial__wrap container-type: inline-size
└─ ol .dial__steps "Index children"
└─ li .dial__step ×n
├─ div .dial__marker > span .dial__num
└─ div .dial__body > h3, p

Solo la escena está animada — todas las demás variables derivan de su --fa-scroll--custom heredado, del progreso anclado (de la receta "Altura de escena", que define --sc-x), y del índice de "Index children" (--fa-i desde 0, --fa-n). El paso al frente es s = q·n − .5, continuo; la distancia de cada paso hasta él es d = i − s. La posición, el resalte, la escala y el texto derivan todos de d — nunca de una ventana por paso.

Esta receta es intensa en CSS (container queries, máscaras con conic-gradient, trigonometría por paso incorporada en propiedades personalizadas). El CSS completo y listo para copiar y pegar — incluido el layout móvil, que mantiene el dial animado sobre una lista de texto apilada — vive en el contenido de referencia de abilities propio del plugin; los valores clave que probablemente querrás ajustar se listan abajo.

Valores que puedes cambiar: colores del anillo (hecho/pendiente), colores del marcador (relleno inactivo, color de texto inactivo, color de texto encendido), tamaño del marcador, tamaño del punto, el espacio entre el anillo y el texto, el ángulo "frontal" (90° = frente a la derecha junto al texto; 180° = abajo, usado en móviles), cuánto crece el marcador frontal, y cuántas pantallas de scroll recibe cada paso en la fórmula de altura de escena (0.6 por defecto).

  • Escena: suavizado del scroll ~0.2s, una fila Custom (no necesita "I am parent"). Pasos (ol): "Index children". Los propios pasos y marcadores no necesitan ningún ajuste de Fancy.
  • El margin-left del marcador para el breakpoint móvil debe ir en el control nativo de margen de ese breakpoint, no en CSS personalizado — una sobrescritura de breakpoint en CSS personalizado pierde frente a una propiedad nativa y los números se salen del círculo.
  • Dale a cada elemento vacío (marcadores, etc.) un ancho/alto mínimo explícito de 0, o el lienzo del constructor los renderiza sobredimensionados.

Problemas comunes:

  • Un paso se enciende medio paso demasiado pronto. Su luz o su texto se controlaban con su propia ventana en lugar de derivarse de d = i − s como arriba.
  • Las esquinas exteriores del anillo siguen visiblemente pintadas en Safari. Una máscara circular usando radial-gradient(closest-side …) en lugar de paradas basadas en longitudes explícitas.
  • El dial es demasiado grande o demasiado pequeño en relación con su propia columna. Se dimensionó a partir del viewport (vw) en lugar de su contenedor (cqw, que necesita container-type en el envoltorio).
  • Soporte de navegadores: color-mix() y las unidades de container query necesitan un navegador bastante reciente — Chrome 111, Safari 16.2 o Firefox 113 como mínimo.