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--customis 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
@propertyrules — without them the bar stays empty, though the track itself still works. 100cqiis the section's width with the scrollbar excluded (hencecontainer-typeon the section). Without container query unit support, use100vwinstead — the last card then stops about one scrollbar's width past the edge.- Without the animation (builder preview off, no JavaScript),
--ht-hand 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.
- Bricks
- Etch
- Scene: one Custom row, scroll smoothing ~0.3s, plus the two
@propertyrules (outside the element's%root% {}block) followed by the.ht__scenerules, with--ht-nset 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-cardper breakpoint on the scene's custom CSS, not through the card's own width control, or the travel distance won't match reality.
- The two
@propertyrules go in a global stylesheet (Style Manager), not inside a class — inside a class, they're ignored. - Scene classes:
fa--custom fa--smooth-30, plus its own.ht__sceneclass with the CSS. - Mobile
--ht-cardvalue nested in@media (max-width: 767px) { … }inside.ht__scene.
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-cardthat don't match the real cards. - The last card stops short of the edge, or goes past it.
--ht-card,--ht-gapor--ht-paddiffer from what the elements actually use. - The progress bar stays empty or full while the track clearly moves. The two
@propertyrules are missing, soatan2()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: hiddenon the stage, or theoverflow-xreset on the section (a Safari-specific issue — see Pinned scenes).
Focus gallery
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-sis the track's position measured in whole items (0 = first item centred). Padding equal to half a screen minus half an item width centres itemiexactly ats = 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).
- Bricks
- Etch
- 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-win the stage class's mobile-breakpoint custom CSS.
- Track classes:
fa--parent fa--custom fa--custom--start-25 fa--custom--end-75 fa--smooth-30. - Nest
:where(&),&:has(> :nth-child(2):last-child)and the@supportsblock inside.gallery__track's own class.
Common problems:
- The track stops at no particular position in some browsers, or never moves. A
var(--gl-s, fallback)can't rescue an unsupportedround()— 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
imgelements tomax-width: 100%— override it withmax-width: noneon 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).
- Bricks
- Etch
- 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-leftmust 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.
- Scene:
fa--custom fa--smooth-20; steps:fa--index. - Phone rules nest as
@media (max-width: 767px) { … }inside each class.
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 − sas 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 needscontainer-typeon 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.