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--customes 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. 100cqies el ancho de la sección sin incluir la barra de scroll (de ahí elcontainer-typeen la sección). Sin soporte de unidades de container query, usa100vwen 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-hy 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.
- Bricks
- Etch
- 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-najustado 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-carddistinto 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.
- Las dos reglas
@propertyvan en una hoja de estilos global (Style Manager), no dentro de una clase — dentro de una clase se ignoran. - Clases de la escena:
fa--custom fa--smooth-30, más su propia clase.ht__scenecon el CSS. - Valor de
--ht-cardpara móvil anidado en@media (max-width: 767px) { … }dentro de.ht__scene.
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-cardque no coinciden con las tarjetas reales. - La última tarjeta se queda corta del borde, o lo sobrepasa.
--ht-card,--ht-gapo--ht-paddifieren 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í queatan2()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: hiddenen el escenario, o el reinicio deoverflow-xen 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-ses 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 elementoiexactamente ens = 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).
- Bricks
- Etch
- 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-wen el CSS personalizado del breakpoint móvil de la clase del escenario.
- Clases de la pista:
fa--parent fa--custom fa--custom--start-25 fa--custom--end-75 fa--smooth-30. - Anida
:where(&),&:has(> :nth-child(2):last-child)y el bloque@supportsdentro de la propia clase de.gallery__track.
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 unround()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
imgamax-width: 100%— sobrescríbelo conmax-width: noneen 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).
- Bricks
- Etch
- 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-leftdel 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.
- Escena:
fa--custom fa--smooth-20; pasos:fa--index. - Las reglas de móvil se anidan como
@media (max-width: 767px) { … }dentro de cada clase.
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 − scomo 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 necesitacontainer-typeen 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.