Skip to main content

Scene height & progress indicators

These two patterns extend the basic pinned scene: one makes a pinned scene's height follow however many items it actually holds, the other builds a thin bar that fills with the progress of any scene or scroll range.

Scene height that follows the item count​

With 3 steps or with 12, each one should get the same amount of scroll to play its own animation. Rather than hard-coding a scene height, this recipe measures how many items are actually present and derives the right height from that — and recomputes the pinned window's progress for exactly that height.

Structure:

section .scene-section overflow-x reset, padding 0
└─ div .scene own Custom row, no Start/End (no "Get parent")
└─ div .scene__stage sticky (see Pinned scenes)
└─ … .scene__item ×n the items being counted, any depth

Children read the scene's --fa-scroll--custom by inheritance; they don't need a row of their own.

CSS:

.scene {
position: relative;
width: 100%;
--sc-x: 4;
--sc-h: calc(var(--fa-scroll--custom) * 0vh + var(--sc-x) * 100vh);
height: var(--sc-h, auto);
}
.scene:has(.scene__item:nth-child(2)) { --sc-x: 2.2; }
.scene:has(.scene__item:nth-child(3)) { --sc-x: 2.8; }
.scene:has(.scene__item:nth-child(4)) { --sc-x: 3.4; }
.scene:has(.scene__item:nth-child(5)) { --sc-x: 4; }
.scene:has(.scene__item:nth-child(6)) { --sc-x: 4.6; }
/* … X = 1 + .6 × k, continuing up to k = 12 (X = 8.2); keep them in ascending order, the last match wins */

.scene__stage {
--sc-q: clamp(0, (var(--fa-scroll--custom) * (var(--sc-x) + 1) - 1) / (var(--sc-x) - 1), 1);
}
  • A scene that's X screens tall pins from progress 1/(X+1) to X/(X+1). --sc-q remaps that exact stretch to a clean 0 → 1, for whatever X turns out to be.
  • :has(.scene__item:nth-child(k)) matches whenever there are at least k items. Without :has() support, X just stays at its default of 4.
  • Without the animation running at all, --sc-h becomes invalid and the scene falls back to its natural height — the stage simply isn't pinned, and its content flows like a normal section.
  • Item i (from 0) of n total is "current" while --sc-q × n sits between i and i + 1. With "Index children" on the items' own parent, --fa-i and --fa-n give you i and n directly.

Values you can change: the .6 multiplier (screens of scroll per item), and the default --sc-x (4, used as a fallback by browsers without :has() support).

  • Scene: add one Custom row with no Start/End. If its children need scroll smoothing, set it here too, since they inherit the smoothed value.
  • The :has() rules go in the scene's own custom CSS, referencing the item's real class name.

Common problems:

  • Progress tuned by eye drifts the moment an item is added or removed. Windows were hard-coded rather than derived from --sc-q and the item's index — derive everything from those instead.
  • The stage gets cut off on short screens in the builder. A fixed height was used instead of the var(--sc-h, auto) fallback pattern.
  • --sc-q looks wrong. The scene element itself was transformed, or is being measured through "Get parent" from somewhere else — it must measure itself, and stay untransformed.

Progress indicator​

A thin bar that fills from 0% to 100% as a scene plays, or as a long list scrolls past a sticky side column.

Structure (horizontal, inside a pinned scene):

div .scroll-progress centres the bar
└─ div .scroll-progress__bar the track: fixed size, overflow hidden
└─ div .scroll-progress__fill "Get parent" + one Custom row — the same window as the animation it reports on

Structure (vertical, beside a list that scrolls normally):

section .list-progress "I am parent", overflow-x reset
└─ div .list-progress__row flex row
├─ div .list-progress__content the long list
└─ div .list-progress__side sticky column, one screen tall
└─ .scroll-progress__bar > .scroll-progress__fill "Get parent" + Custom, Start 20, End 80

CSS:

.scroll-progress { display: flex; justify-content: center; }
.scroll-progress__bar {
width: 300px; height: 3px;
background: #d4d4d8; border-radius: 10em; overflow: hidden;
}
.scroll-progress__fill {
height: 100%; min-width: 0;
background: var(--primary, #2563eb);
width: calc(var(--fa-scroll--custom, 0) * 100%);
}

/* vertical variant */
.list-progress__row { display: flex; justify-content: space-between; }
.list-progress__content { width: calc(100% - 4rem); }
.list-progress__side {
position: sticky; top: 0; height: 100vh; width: 1rem;
display: flex; align-items: center; justify-content: center;
}
.list-progress__side .scroll-progress__bar { width: 4px; height: 180px; }
.list-progress__side .scroll-progress__fill { width: 100%; min-height: 0; height: calc(var(--fa-scroll--custom, 0) * 100%); }

The fill element can also skip having a row of its own and simply inherit --fa-scroll--custom from an ancestor that already has one — the value is the same either way.

Values you can change: use the same Start/End (and the same scroll smoothing value) as whatever animation the bar is reporting on, or the two will visibly drift apart. For the pinned horizontal track specifically, use the progress bar built into that recipe instead — its scene height tracks the cards directly, so a fixed window here would drift from it.

  • Fill: "Get parent" + one Custom row with the Start/End you're reporting on.
  • Bar and fill are empty elements — give their classes an explicit minimum width/height of 0, or the builder canvas shows them at its default 90px placeholder size.
  • Vertical variant: the section needs "I am parent" and the overflow-x reset; the .list-progress__side … descendant rules go in the side column's own custom CSS, written out in full.

Common problems:

  • The bar is full from the very start, or never moves at all. No "Get parent" (so it's measuring itself, which inside a pinned stage never changes), or no "I am parent" above it.
  • It runs ahead of, or behind, the animation it's meant to track. A different window, or a different scroll smoothing value, than the thing it's reporting on.
  • The side column doesn't stick. Missing overflow-x reset on the section, or the column isn't a full screen tall.
  • The fill shows a visible sliver even at 0%. A builder default minimum width/height on an empty element — set it to 0 explicitly.