Skip to main content

Scroll math & custom CSS

note

Most effects are built entirely from the ready-made presets in Scroll trigger — you don't need any of this page for everyday use. This page is for developers and advanced builds: it's what the Custom preset gives you, and how the Advanced recipes are actually built.

The Custom preset in Scroll trigger doesn't animate anything by itself — it only exposes the element's windowed scroll progress as a CSS custom property, for you to use in your own CSS. This is how you drive more than one property from a single scroll row, or build an effect none of the built-in presets cover.

The variables​

VariableWhere it livesValue
--fa-percentInline on every scroll-trigger elementRaw progress, 0 to 1, with no window applied
--fa-scroll--customElements with a Custom rowThe windowed output of that row (0 to 1, following its Start/End/From/To/Invert)
--fa-scroll--{preset}Elements with that presetThe windowed output of that preset, e.g. --fa-scroll--fade
--fa-i, --fa-nSee Sequence / Index / CoverA child's position (from 0) and the total count

These are all regular CSS custom properties, which means they inherit — a single Custom row on a parent element can drive any number of descendants at once. A descendant with its own Custom row gets its own value instead, shadowing the parent's for everything below it.

Writing the CSS​

Write the CSS in the element's own custom CSS field, using %root% as the selector (Bricks replaces it with the element's real id when it renders):

%root% {
opacity: var(--fa-scroll--custom, 1);
transform: translateY(calc((1 - var(--fa-scroll--custom, 1)) * 60px))
scale(calc(.9 + var(--fa-scroll--custom, 1) * .1));
}

In a global class, %root% stands for the class selector itself.

Always give your var() a fallback. The variable doesn't exist at all when the scroll script hasn't run yet (no JavaScript, or an editor preview) — or when "Disable from tablet/mobile" is active at that width. The fallback value is what visitors in that state will see: use var(--fa-scroll--custom, 0) when the unanimated look should match the start of the animation, or var(--fa-scroll--custom, 1) when it should match the end (for something that must stay visible and in place without JavaScript).

Useful math patterns​

In the table below, v stands for var(--fa-scroll--custom, <fallback>).

NeedCSS
Map to a range A → Bcalc(A + (B - A) * v) — e.g. calc(20px + 180px * v)
A sub-window a to b of one rowclamp(0, (v - a) / (b - a), 1) — e.g. clamp(0, (v - .25) / .5, 1)
Fade in during the first quartermin(v * 4, 1)
Fade out during the last quartermin((1 - v) * 4, 1)
In, hold, then out, from one rowmin(v * 4, (1 - v) * 4, 1)
A different phase per childclamp(0, v * var(--fa-n, 1) - var(--fa-i, 0), 1) — see Sequence / Index / Cover
"Is the animation actually running?"Define --fa-on: calc(var(--fa-scroll--custom) * 0 + 1); with no fallback on purpose, then read it elsewhere as var(--fa-on, 0)

That last pattern is handy for layout that only makes sense while animated — captions sharing one grid cell, say, which should stack as an ordinary list when there's no animation running:

%root% {
--row: calc(var(--fa-scroll--custom) * 0 + 1);
grid-row: var(--row, auto);
}

Prefer one row with several phases cut using clamp() over several separate rows stacked on the same element — it keeps everything on one timeline and is much easier to reason about.

Any window, not only multiples of 5​

Start and End only accept multiples of 5 in the builder panel. To use an exact value like .33, leave Start/End empty in the panel and set the underlying variables directly in CSS instead:

%root% {
--fa-start--custom: .33;
--fa-end--custom: .67;
}

Don't combine this with a Start/End value set in the panel for the same preset — which one actually wins then depends on CSS specificity and source order, which is fragile. Also note that windows inherit: a nested Custom row with an empty Start/End uses its nearest ancestor's --fa-start--custom / --fa-end--custom rather than defaulting to 0–100. Give nested rows their own explicit Start/End, or reset them: %root% { --fa-start--custom: 0; --fa-end--custom: 1; }.

A counting number tied to scroll​

To animate an integer (say, a stat counting from 2015 to 2025), register it as a typed custom property and read it with a CSS counter:

@property --year {
syntax: '<integer>';
inherits: true;
initial-value: 2015;
}
.year {
--year: calc(2015 + var(--fa-scroll--custom, 0) * 10);
counter-reset: year var(--year);
}
.year::before {
content: counter(year);
}

The @property rule must sit at the top level of your CSS — in Bricks, outside the %root% {} block; in Etch, in a global stylesheet rather than inside a class. This needs Chrome; Safari 16.4 and Firefox 128 or later also support it.

Video walkthrough​

The hard way

This video builds the animation by writing classes and custom CSS by hand, the developer route. For most effects the ScrollTrigger Animations panel gets there faster with no code: use this only when no preset fits.

Recorded with Fancy Animations 2.0 (August 2024). The panel looks different today; the idea it explains still applies.

Common problems​

  • Text pieces sharing a grid cell overlap in the builder or without JavaScript. The layout was switched on by something other than the animation itself (a body class, say). Gate it on var(--fa-on, 0) as shown above instead.
  • Progress seems to drift while the element itself scales or moves. The plugin measures the element's box after transforms are applied — if the measured element (or its "I am parent") is itself transformed, its own measurement keeps shifting. Keep it untransformed and animate a child instead.
  • A round() or mod() based value falls back to 0 instead of your fallback. A var() fallback doesn't rescue an unsupported CSS function — the whole declaration is invalid. Put the modern function inside @supports, with a safe plain value outside it.
  • A property quietly becomes 0 or none. You're reading a variable that doesn't exist anywhere — double-check the exact name, or add a fallback.
  • A breakpoint override inside @media/@supports never seems to apply. Bricks moves every @media/@supports block in custom CSS to the top of the generated stylesheet when it saves, so a rule meant to override something later in the file loses on source order. Write breakpoint-specific CSS in the element's per-breakpoint custom CSS field (e.g. "Custom CSS — Mobile Landscape") instead of a hand-written @media block, and give any default value you're overriding zero specificity with :where(.your-class) so order can't matter.