Skip to main content

Horizontal tracks & galleries

Three recipes that turn vertical scrolling into horizontal motion inside a pinned scene: a sideways-sliding track of cards, a gallery that eases each photo into focus in turn, and a circular "dial" of numbered steps.

See it live: Horizontal scroll on fancyanimations.com.

Pinned horizontal track​

While the stage is pinned, a row of cards slides from right to left and stops exactly when the last card reaches the right edge. The scene is exactly as tall as the track needs: the cards move as far as the page scrolls, so 3 cards make a short scene and 12 cards a long one — never too slow and never too fast, whatever the count.

Structure:

section .ht overflow-x reset, padding 0, container-type: inline-size
└─ div .ht__scene one Custom row with no Start/End (measures itself)
└─ div .ht__stage sticky, overflow hidden
├─ div .ht__track no Fancy settings — reads the scene's progress by inheritance
│ ├─ div .ht__card
│ └─ …
└─ div .ht__bar optional progress bar
└─ div .ht__fill

CSS:

/* Two lengths the CSS must divide: registered, so the browser resolves them to px
(atan2() accepts one length per argument, like 100vh or 900px, but not an expression that
mixes units, like 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; /* number of cards: set it to match the real count */
--ht-card: min(450px, 80vw); /* card width */
--ht-gap: 2rem;
--ht-pad: 1.5rem; /* stage side padding */
--ht-speed: 1; /* 1 = cards move as fast as the page; 1.5 = slower */
/* how far the track travels: its total width minus the stage width */
--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);
/* how far the page has scrolled into the pinned stretch */
--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; }
/* progress 0 → 1: tan(atan2(a, b)) is a / b for two lengths */
.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)); }
  • The scene's Custom row has no window, so --fa-scroll--custom is just the scene's raw progress; the stage and everything in it read it by inheritance — only the scene needs a Fancy setting.
  • The track moves exactly as far as the page has scrolled into the pinned stretch, up to the full travel distance — a precise 1:1 relationship with the scroll; the progress bar divides the same value by the same travel, so they can never drift apart from each other.
  • Keep both @property rules — without them the bar stays empty, though the track itself still works.
  • 100cqi is the section's width with the scrollbar excluded (hence container-type on the section). Without container query unit support, use 100vw instead — the last card then stops about one scrollbar's width past the edge.
  • Without the animation (builder preview off, no JavaScript), --ht-h and the transform both become invalid, so the scene takes its natural height and shows the first few cards as a normal row.

Values you can change: --ht-n (update whenever cards are added/removed), --ht-card/ --ht-gap/--ht-pad (keep these equal to what the real elements use — the travel distance is computed from them), --ht-speed (1 feels like the page itself; 1.2–1.5 gives each card more screen time; below 1 the track runs faster than the scroll). With very few cards the travel can end up at 0 — the scene is then just one screen and nothing moves, which is correct, not a bug. Scroll smoothing around 0.3s on the scene hides the stutter a slowing mouse wheel would otherwise show.

  • Scene: one Custom row, scroll smoothing ~0.3s, plus the two @property rules (outside the element's %root% {} block) followed by the .ht__scene rules, with --ht-n set to the real count.
  • Stage, track, cards, bar, fill: their CSS in each element's own custom CSS. None of them need their own Fancy settings — "Get parent" or a row on the track would replace the inherited progress with something else.
  • Set a different --ht-card per breakpoint on the scene's custom CSS, not through the card's own width control, or the travel distance won't match reality.

Common problems:

  • The track moves too slowly or too quickly. The scene height isn't actually derived from the track — a fixed height, or --ht-n/--ht-card that don't match the real cards.
  • The last card stops short of the edge, or goes past it. --ht-card, --ht-gap or --ht-pad differ from what the elements actually use.
  • The progress bar stays empty or full while the track clearly moves. The two @property rules are missing, so atan2() is getting mismatched units and the whole declaration is invalid.
  • Nothing moves, or the track jumps around. The track or stage has its own Fancy row, or "Get parent" — it should only inherit the scene's progress.
  • Horizontal scrollbar appears on the page. Missing overflow: hidden on the stage, or the overflow-x reset on the section (a Safari-specific issue — see Pinned scenes).

While the stage is pinned, photos pass through the centre of the screen one by one. The one in focus shows at full size with its caption; the others shrink and dim, and each image drifts gently inside its own frame as it passes. One scroll row drives the whole track; every item works out its own phase from its position and the total count, so adding a photo needs no retuning at all.

Structure (a 300vh pinned scene, pinned 25–75%):

div .pin__stage customisation variables (below)
└─ div .gallery__track "Get parent" + Custom 25–75 (+ scroll smoothing ~0.3s)
└─ div .gallery__item ×n 2 to 12 photos
├─ 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; }
/* … one rule per count, up to 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; }
/* … up to 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 is the track's position measured in whole items (0 = first item centred). Padding equal to half a screen minus half an item width centres item i exactly at s = i - 1.
  • Soft magnet: the track slows to half speed right at each photo, but never actually stops — any true rest zone feels like a jump with a mouse wheel (the track halts while the hand keeps turning, then suddenly catches up).
  • Counter, "04 / 07" style, for any photo count: since each item already knows its own position and the total through "Index children", its caption can print them directly:
.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) " "; }

This alternative needs "Index children" on the track instead of the :has()/:nth-child lists above — swap var(--fa-n, 2) in for var(--gl-n, 2) and var(--fa-i, 0) for (var(--gl-i) - 1). Both variables only exist once JavaScript has actually run, which is fine here, since without the animation the track simply rests on the first photo anyway.

Values you can change: --gl-w (item width, already capped by screen height so it fits short screens — give phones a wider one), --gl-o (image overhang, i.e. parallax strength), --gl-shrink/--gl-dim (how much out-of-focus photos shrink and dim), and the scene height (taller = slower, which helps with a lot of photos).

  • Track: "Get parent", scroll smoothing ~0.3s (needs 3.1.1 — leave it out on older sites), one Custom row, Start 25, End 75.
  • Phone track width: override --gl-w in the stage class's mobile-breakpoint custom CSS.

Common problems:

  • The track stops at no particular position in some browsers, or never moves. A var(--gl-s, fallback) can't rescue an unsupported round() — the whole property using it becomes invalid. Keep the plain defaults outside @supports, exactly as shown above.
  • Photos snap or visibly jump between items. A hard stop, or a long rest, at each photo instead of the soft-magnet slowdown.
  • Stutter with a mouse wheel. The track moves several times faster than the page — add scroll smoothing ~0.3s on the track.
  • The image appears to shrink inside its own frame. The theme limits all img elements to max-width: 100% — override it with max-width: none on the image.

Dial: steps on a turning circle​

Process steps sit as numbered markers around a circle. Scrolling turns the circle so each step, in turn, reaches the front next to its text; a ring fills in behind it from a starting dot. On phones, the dial stays animated, stacked above the text instead of beside it.

Structure:

section .dial-section overflow-x reset, padding 0
└─ div .dial__scene Custom row, no Start/End; scene height from "Scene height" above
└─ div .dial__stage sticky (see Pinned scenes)
└─ 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

Only the scene itself is animated — every other variable derives from its inherited --fa-scroll--custom, the pinned progress (from the "Scene height" recipe, which defines --sc-x), and the index from "Index children" (--fa-i from 0, --fa-n). The step at the front is s = q·n − .5, continuous; each step's distance to it is d = i − s. Position, highlight, scale and text all derive from d — never from a per-step window.

This recipe is CSS-heavy (container queries, conic-gradient masks, per-step trigonometry baked into custom properties). The full, copy-pasteable CSS — including the phone layout, which keeps the dial animated above a stacked text list — lives in the plugin's own abilities reference content; the key values you're likely to adjust are listed below.

Values you can change: ring colours (done/todo), marker colours (idle fill, idle text colour, lit text colour), marker size, dot size, the gap between the ring and the text, the "front" angle (90° = front at the right next to the text; 180° = at the bottom, used on phones), how much the front marker grows, and how many screens of scroll each step gets in the scene-height formula (0.6 by default).

  • Scene: scroll smoothing ~0.2s, one Custom row (no "I am parent" needed on it). Steps (ol): "Index children". The steps and markers themselves need no Fancy settings.
  • The marker's phone-breakpoint margin-left must go in the native margin control for that breakpoint, not in custom CSS — a breakpoint override in custom CSS loses to a native property and the numbers drift out of the circle.
  • Give every empty element (markers, etc.) an explicit minimum width/height of 0, or the builder canvas renders them oversized.

Common problems:

  • A step lights up half a step too early. Its light or text was driven by its own window instead of being derived from d = i − s as above.
  • The ring's outside corners stay visibly painted in Safari. A circular mask using radial-gradient(closest-side …) instead of explicit length-based stops.
  • The dial is too big or too small relative to its own column. It was sized from the viewport (vw) instead of its container (cqw, which needs container-type on the wrapper).
  • Browser support: color-mix() and container query units need a fairly recent browser — Chrome 111, Safari 16.2 or Firefox 113 at minimum.