CSS Scroll-Driven Animations vs GSAP ScrollTrigger
GSAP’s ScrollTrigger plugin and native CSS animation-timeline solve the same surface-level problem — driving animation progress from scroll position — with fundamentally different machinery. ScrollTrigger reads scroll offsets in JavaScript and writes inline styles on the main thread every frame; a CSS scroll timeline hands the mapping to the browser, which interpolates compositor-safe properties without touching the main thread at all. That architectural difference, explained in depth in the rendering pipeline guide this page belongs to, determines which tool wins for a given effect. This page compares the two at the threading, capability, and cost level, ending with a coexistence strategy for teams already invested in GSAP. For the broader decision framework across all libraries, see when to use CSS animations over JavaScript libraries; the section overview is Core Animation Fundamentals & Browser Mechanics.
When to Use This Approach
Prefer native CSS scroll-driven animations when all of the following hold:
- The effect is a pure function of scroll progress — parallax shifts, reveal-on-enter, progress bars, scale/fade scrubs. No branching on velocity, direction history, or application state mid-scroll.
- You are animating
transform,opacity, orfilter— the compositor-safe property set. Anything else runs on the main thread even with a native timeline, erasing the threading advantage. - Your support floor tolerates progressive enhancement: Chrome and Edge 115+ get the native path, other engines get a static or simplified fallback behind
@supports. - The effect is high-frequency — it updates continuously during scroll rather than toggling once at a threshold. (For one-shot threshold toggles, compare against the IntersectionObserver approach first — an observer plus a CSS transition is often simpler than either tool discussed here.)
Prefer ScrollTrigger when any of these hold:
- You need pinning with automatic spacer management, or a multi-tween scrubbed sequence where a dozen elements animate in a choreographed order across one scroll span.
- You need lifecycle callbacks (
onEnter,onLeave,onUpdate,onToggle) to synchronize non-CSS state — analytics events, canvas/WebGL scenes, video seeking, ARIA updates. - You need identical behavior across all current browsers today, without maintaining a second fallback code path.
- The animation is coupled to snap logic that must coordinate scroll resting points with tween progress.
Threading Model: Compositor Interpolation vs rAF Scrubbing
ScrollTrigger’s update loop is main-thread JavaScript. On each scroll event (batched to requestAnimationFrame via GSAP’s ticker), it reads the scroll offset, maps it to tween progress, computes interpolated values, and writes inline style attributes. Every step competes with everything else the main thread does: framework rendering, event handlers, garbage collection, hydration. If a long task occupies the thread, the queued rAF callback fires late and the scrubbed element visibly lags the scroll position — the “rubber-banding” signature of main-thread scroll animation.
A declarative animation-timeline: scroll() on a compositor-safe property inverts this. The keyframes are resolved once during style calculation; from then on, the compositor thread reads the scroll offset directly and interpolates between pre-rasterized GPU textures at the display’s refresh rate. No JavaScript executes during scroll. No style recalculation, layout, or paint occurs. A blocked main thread cannot delay the animation because the animation never visits the main thread.
One asymmetry deserves emphasis: the CSS advantage is conditional on the property set. Declare animation-timeline: scroll() on keyframes animating height or background-color and the browser must run layout or paint per frame on the main thread — CSS syntax with ScrollTrigger’s threading profile, minus the escape hatches. The threading argument for native timelines is really an argument for compositor-safe properties driven by native timelines.
Capability Gaps: What ScrollTrigger Does That CSS Cannot
Being honest about the gap matters, because most production ScrollTrigger usage leans on features the CSS API deliberately does not have.
Pinning. pin: true fixes an element while a scroll span plays out, inserts spacer elements so the document doesn’t collapse, and un-pins cleanly at both ends. CSS offers position: sticky, which covers the simple case and pairs well with a view() timeline animating the stuck element’s children. But sticky positioning cannot pin an element beyond its parent’s bounds, cannot create scroll distance out of nothing, and has no pinSpacing equivalent. Long pinned scenes (“scroll 3000px while this hero stays put and five layers animate”) remain ScrollTrigger territory.
Complex sequenced scrub timelines. A GSAP timeline composes dozens of tweens with relative position parameters ("<", "+=0.5", labels) and scrubs the whole composition as one unit. The CSS analog is manual: each element gets its own animation and you partition the scroll span with animation-range percentages. That works for four elements and becomes unmaintainable percentage arithmetic at fifteen. There is no native way to nest, reverse, or re-time a group of scroll-driven animations as a single object.
Scroll snapping coordination. ScrollTrigger’s snap option animates the scroll position to the nearest section boundary after input ends, synchronized with tween progress. CSS scroll snap (scroll-snap-type) composes with scroll timelines but is a separate mechanism with its own heuristics — you cannot express “snap to wherever tween progress is closest to a label.” When snap points and animation states must agree exactly, the library keeps both in one system.
Callbacks and lifecycle. Scroll-driven CSS animations fire no animationstart/animationend events at range boundaries and expose no per-frame progress callback. Anything that needs to know the progress value — a canvas scene, a <video> scrub, a live region announcement — needs JavaScript. (The WAAPI ScrollTimeline object narrows this gap for reads, but polling it still costs main-thread work.)
| Capability | CSS scroll timelines | GSAP ScrollTrigger |
|---|---|---|
Scroll-linked transform/opacity/filter scrub |
Yes — compositor thread | Yes — main thread |
| Pinning with spacer management | Partial (position: sticky) |
Yes |
| Nested, labeled, sequenced scrub timelines | Manual animation-range math |
Yes |
| Snap coordinated with tween progress | Separate CSS scroll snap only | Yes |
| Enter/leave/progress callbacks | No | Yes |
| Velocity/direction-aware logic | No | Yes |
| Works with zero JavaScript | Yes | No |
| Blocked by main-thread load | No (compositor-safe props) | Yes |
Bundle Size and the Zero-Dependency Argument
GSAP core plus the ScrollTrigger plugin typically adds on the order of 35–45 kB of minified, gzipped JavaScript, plus parse and evaluation cost on every page load — paid before a single pixel animates, on every device, including the majority of sessions where the user never scrolls past the fold. The CSS approach ships a few hundred bytes of declarations inside a stylesheet you were sending anyway, requires no initialization at hydration time, and cannot throw, race a framework’s mount order, or need version upgrades.
Bundle size is rarely decisive for a content site with one hero effect. It matters on performance-budgeted pages where 40 kB of script measurably moves interactivity metrics, and in design-system components: a scroll-linked progress bar published as a component should not force GSAP into every downstream bundle when six lines of CSS express it natively.
Behavior Under Main-Thread Load
This is where the two approaches diverge most visibly in production, and it is worth verifying rather than assuming — the DevTools profiling workflow for scroll animations shows both signatures clearly.
Under a 4Ă— CPU throttle with a synthetic 200 ms long task injected per second:
- A ScrollTrigger scrub freezes for the duration of each long task, then jumps to the current scroll position when the rAF queue drains. Users perceive stutter and rubber-banding.
scrub: 1(smoothed scrubbing) masks small stalls by design — it lerps toward the target — but converts them into visible lag instead. - A CSS timeline on compositor-safe properties continues interpolating at the display refresh rate throughout the long task. The main-thread flame chart shows the long task; the frames track above it stay green.
The practical consequence: on low-end hardware, where long tasks are the norm during scroll, native timelines deliver visibly smoother high-frequency effects than any main-thread library can. The library is not slow — it is on the wrong thread.
When a JavaScript Library Remains the Right Call
None of the above makes ScrollTrigger obsolete. Choose the library when:
- The design is a scrollytelling scene — pinned sections, layered sequences, labels, snap — where the orchestration features are the product. Rebuilding them from
sticky+animation-rangepercentages costs more than the threading benefit is worth. - Progress must drive non-DOM output: WebGL cameras, canvas particle systems, video scrubbing. These need a JavaScript progress value every frame anyway, so the main-thread cost is already sunk.
- Uniform behavior across every current engine is a hard requirement and a static fallback is unacceptable.
- Velocity, direction, or interruption logic matters — e.g. hide-on-scroll-down headers with momentum thresholds. CSS timelines expose position, never velocity.
Coexistence Strategy: Orchestrate in JavaScript, Scrub in CSS
The strongest production pattern is not either/or. Let the library own low-frequency orchestration — pinning, section sequencing, callbacks, snap — and let native CSS timelines own high-frequency per-pixel effects that must stay smooth under load.
/* High-frequency effects: native, compositor-interpolated */
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.scene-backdrop {
animation: backdrop-drift linear both;
animation-timeline: scroll(root block);
}
@keyframes backdrop-drift {
from { transform: translateY(0) scale(1); }
to { transform: translateY(-12%) scale(1.06); }
}
}
}
// Low-frequency orchestration: pinning, callbacks, snap stay in the library
gsap.registerPlugin(ScrollTrigger);
ScrollTrigger.create({
trigger: '.scene',
start: 'top top',
end: '+=2400', // pinned scroll distance the CSS API cannot create
pin: true,
snap: 1 / 3, // section snapping coordinated with progress
onToggle: (self) => {
// Callback-driven state: analytics, ARIA, class toggles
document.body.classList.toggle('scene-active', self.isActive);
},
});
Two rules keep the hybrid stable. First, never let both systems animate the same property on the same element: ScrollTrigger writes inline styles, which beat the cascade and silently override animation-timeline keyframe output. Partition by element, or at minimum by property. Second, keep the library’s scrubbed tweens off the critical smoothness path: if an effect must stay fluid while the app hydrates or fetches, it belongs in CSS; if it merely needs to be correct when the thread is free, the library is fine.
Verification
Confirm each half of a hybrid (or a migration) is running on the intended thread:
- Performance panel, throttled recording. Set 4Ă— CPU throttling, record a 5-second scroll through the effect. For CSS-driven elements, the main-thread track must show no
Function Call,Recalculate Style,Layout, orPaintentries attributable to them during steady scroll. ScrollTrigger’s rAF ticker will appear as recurringAnimation Frame Firedblocks — expected for the orchestration layer, a regression for anything you migrated to CSS. - Long-task stress test. Inject
while (performance.now() - t0 < 200) {}on an interval, scroll, and watch: CSS-scrubbed elements keep moving; library-scrubbed elements freeze. If a supposedly native effect freezes too, its keyframes animate a non-composited property. - Timeline binding check. In the console,
document.querySelector('.scene-backdrop').getAnimations()[0].timelineshould be aScrollTimeline(orViewTimeline) instance, notDocumentTimeline. ADocumentTimelinethere meansanimation-timelinedid not resolve and the animation is free-running on its duration clock. - Style-conflict audit. Inspect migrated elements for leftover inline
transform/opacitystyles written by a previous library pass —gsap.set()residue overrides the CSS animation output and is the most common “it does nothing” cause in hybrids.
Edge Cases and Gotchas
Inline-style collisions are silent. When ScrollTrigger and a CSS timeline touch the same property, there is no warning in any engine; the inline style simply wins. During incremental migrations, call gsap.set(el, { clearProps: 'transform,opacity' }) after killing a trigger, or the dead tween’s last frame persists forever.
ScrollTrigger.refresh() and layout-affecting CSS animations. ScrollTrigger measures trigger positions at refresh time. If a CSS scroll-driven animation changes an element’s geometry (a non-composited property, or a transform combined with position: sticky boundaries), those measurements drift as the user scrolls, and pinned scenes start or end at the wrong offset. Keep CSS-animated elements geometry-neutral, or exclude them from trigger measurement paths.
Smoothed scrubbing desynchronizes the two systems. scrub: 1 intentionally lags the scroll position; a native timeline never lags. A library-scrubbed foreground gliding over a CSS-scrubbed background produces a subtle parallax error that looks like a bug. Use scrub: true (exact) for any tween that must stay spatially aligned with a native effect.
Scroller smoothing libraries hijack the scroll position. Smooth-scrolling wrappers that translate a proxy container instead of natively scrolling leave scroll() timelines reading a scroll offset that no longer moves. Native timelines require native scrolling; if a smoothing wrapper is non-negotiable, everything must stay in the library.
Reduced-motion parity. A prefers-reduced-motion media query disables the CSS half declaratively, but the library half keeps running unless you gate it in JavaScript with matchMedia('(prefers-reduced-motion: reduce)'). Shipping a hybrid where only the native half respects the preference is a common audit failure.
Browser-Specific Notes
Chrome and Edge (115+). Full scroll(), view(), named timelines, and animation-range support. The Animations drawer shows scroll-driven animations with a progress scrubber, which makes hybrid debugging tractable — you can see the native half’s binding while breakpointing the library half.
Firefox. Scroll-driven animations are implemented behind a preference in Nightly builds and are not yet enabled by default in release Firefox. Ship the @supports (animation-timeline: scroll()) guard and treat Firefox as a fallback target: either the static no-animation state or the library path.
Safari. Scroll-driven animation support is in active development and appears in Technology Preview builds; treat current release Safari as unsupported and rely on the same @supports guard. Where coverage is required today, the scroll-timeline polyfill provides the CSS syntax at main-thread cost — which, notably, is the same threading profile as ScrollTrigger, so the polyfill neutralizes the performance argument in engines that need it.
ScrollTrigger everywhere. The library’s rAF-and-inline-styles model works in every current engine — precisely its portability advantage, and why a guard-gated native path plus a library fallback is a progressive-enhancement strategy in itself.
The summary: native CSS scroll timelines win on threading, weight, and resilience for continuous compositor-safe effects; ScrollTrigger wins on orchestration, callbacks, pinning, and uniform support. Production scroll experiences increasingly use both, partitioned by frequency and thread.
Related
- Debugging scroll animation timing functions — verifying easing and keyframe playback in the native half
- Animating transform vs top/left in scroll-driven effects — why the property choice decides which thread you are on
- Smooth parallax scrolling without JavaScript — a worked native replacement for a classic library effect
- Building an IntersectionObserver fallback for scroll-driven animations — the no-library fallback path for unsupported engines
- Implementing prefers-reduced-motion — keeping both halves of a hybrid accessible