Scroll math & custom CSS
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
| Variable | Where it lives | Value |
|---|---|---|
--fa-percent | Inline on every scroll-trigger element | Raw progress, 0 to 1, with no window applied |
--fa-scroll--custom | Elements with a Custom row | The windowed output of that row (0 to 1, following its Start/End/From/To/Invert) |
--fa-scroll--{preset} | Elements with that preset | The windowed output of that preset, e.g. --fa-scroll--fade |
--fa-i, --fa-n | See Sequence / Index / Cover | A 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
- Bricks
- Etch
- Gutenberg
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.
Write the same declarations directly inside the element's own class in the Style Manager:
.my-element {
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));
}
Choose "Custom" as the Animation in the Scroll Trigger Animations panel, then write the CSS against a class you've added to the block in Advanced → Additional CSS class(es).
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>).
| Need | CSS |
|---|---|
| Map to a range A → B | calc(A + (B - A) * v) — e.g. calc(20px + 180px * v) |
A sub-window a to b of one row | clamp(0, (v - a) / (b - a), 1) — e.g. clamp(0, (v - .25) / .5, 1) |
| Fade in during the first quarter | min(v * 4, 1) |
| Fade out during the last quarter | min((1 - v) * 4, 1) |
| In, hold, then out, from one row | min(v * 4, (1 - v) * 4, 1) |
| A different phase per child | clamp(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
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.
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()ormod()based value falls back to 0 instead of your fallback. Avar()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.
- Bricks
- A breakpoint override inside
@media/@supportsnever seems to apply. Bricks moves every@media/@supportsblock 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@mediablock, and give any default value you're overriding zero specificity with:where(.your-class)so order can't matter.