The Demo Is Not the Migration
Every post telling me to delete my animation library opens with the same snippet.
.progress-bar {
animation: grow linear;
animation-timeline: scroll();
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Zero bytes. Native platform. The browser does it now.
That code is real, it works, and it is genuinely great. It is also not the decision.
I lead a team that builds animation-heavy sites, so when scroll-driven animations got real browser support I took the question seriously. I expected to come out with a smaller bundle. I came out with two conclusions that sit oddly together: this is the most fun I have had with the platform in years, and I am not removing GSAP from a single project.
The reason is not that CSS is worse. It is that a stack decision is not scored on its best example.
How this decision actually works
Nobody switches stacks because the new thing is prettier. You switch when everything you currently do has an equivalent in the new thing, or when the gaps are small enough to absorb.
So the question is never "can CSS do a progress bar." It is: go through the animations that exist in your codebase right now, every one of them, and find the ones with no equivalent. Those are the whole decision. The happy path is not information, because the happy path is what the demo already showed you.
And there is arithmetic that people skip. If forty animations move to CSS and three cannot, the library is still in your bundle. You did not save 35 KB. You saved nothing, and now you maintain two animation systems, two mental models, two sets of debugging habits, and a rule about which one owns what. Partial migration is often more expensive than either endpoint.
That reframes what the rest of this article is for. It is not a comparison table. It is the inventory: what has an equivalent, what has a partial one, and what has none. I would rather you finish it able to run the same audit on your own projects than agree with my conclusion.
Starting with the thing almost every comparison gets wrong.
CSS can scrub. That was never the gap.
Scrubbing is exactly what animation-timeline does. Animation progress is tied to scroll position, one to one. The snippet at the top of this article is a scrubbed animation, running off the main thread, with no library.
What CSS does not have is smoothed scrub:
gsap.to('.panel', {
x: -1000,
scrollTrigger: { trigger: '.section', scrub: 1 }
})
scrub: true is one to one, same as CSS. scrub: 1 means the animation takes a second to catch up to the scroll position. It lags behind you. It eases toward the target instead of snapping to it.
That single digit is a large part of why GSAP work feels expensive and CSS scroll animations can feel mechanical. There is no CSS-only equivalent.
There is, however, a four kilobyte one, and it is probably already in your project. I come back to that below, because it is the most interesting thing I found while writing this.
What CSS is genuinely great at
I want to be fair before I list limits, because the limits are not a reason to ignore this. Some of it is better than what we were doing.
Reveals on enter, the most common item in any brief:
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
That replaces an IntersectionObserver, a class toggle, and the teardown for both. I have written the JavaScript version of this hundreds of times and I do not miss it.
Progress bars are one declaration. Parallax is a keyframe with animation-range: cover. And named timelines are the part people overlook, because they unlock layouts where the scroll container is not the animated element:
.gallery {
overflow-x: auto;
scroll-timeline: --gallery inline;
}
.indicator {
animation: grow linear;
animation-timeline: --gallery;
}
Three things push me toward CSS that have nothing to do with capability. It runs on the compositor thread, which a ScrollTrigger onUpdate does not, and on a mid-range Android that gap is visible. It has no lifecycle to get wrong, which removes an entire class of bug on client-side routed sites. And it costs nothing.
Hold onto that first one. If you run WebGL it reverses completely, and that is the last section of this article.
So I use it. Just not for everything.
The refresh problem, which CSS simply does not have
This is the advantage I underrated, and if your pages have accordions, filters, lazy content or late-loading images, it is probably the one that matters most to you.
ScrollTrigger measures start and end positions when it is created and caches them. Change the page height afterwards and those numbers are stale.
GSAP's own docs are direct about it: ScrollTrigger refreshes automatically when the viewport resizes, but a value you hardcoded at creation will not update unless you made it function-based. And if you load images without width and height attributes, or fetch content that affects layout, you normally have to call ScrollTrigger.refresh() yourself in the callback.
So real code ends up looking like this:
ScrollTrigger.create({
trigger: '.section',
start: () => 'top top',
end: () => '+=' + panel.offsetHeight,
invalidateOnRefresh: true,
scrub: true
})
accordion.addEventListener('toggle', () => ScrollTrigger.refresh())
new ResizeObserver(() => ScrollTrigger.refresh())
.observe(document.querySelector('.dynamic-grid'))
Function-based start and end so they recompute. invalidateOnRefresh so recorded tween values are discarded. A refresh call on every DOM mutation you can anticipate. A ResizeObserver for the ones you cannot.
And refresh brings its own problems. It re-measures every trigger on the page, so it is not cheap when you have forty of them. Refresh order matters, which is why refreshPriority exists. Internally it jumps scroll position to zero so every element sits at its natural position, which is why scroll-behavior: smooth on your html element quietly breaks it. And progress is not preserved across a refresh unless you save and restore it yourself with refreshInit and refresh listeners.
None of this is a knock on ScrollTrigger. It is the unavoidable cost of caching measurements in userland.
The CSS version of that code is the CSS version of any other scroll animation:
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
Open an accordion, load a grid, swap a font, filter a list. Nothing to call. A scroll timeline is not a stored number, it is derived from layout and evaluated as part of it. When the scroll range changes, the range changes. There is no cache to invalidate because there was never a cache.
Two honest caveats.
The underlying physics do not disappear. If the scroll range changes while an animation is mid-progress, the animation's position remaps immediately to the new range, so you can still get a visual jump when something expands above the fold. The difference is that CSS self-corrects and lands correct, whereas a stale ScrollTrigger stays wrong until you refresh it. Wrong-then-right beats silently wrong.
And if you run Lenis, it has to know the new dimensions too. It observes wrapper and content size by default and exposes lenis.resize(), so this is mostly handled, but it is one more thing that can fall out of sync.
For a marketing site with static sections, this whole section is irrelevant. For anything with expandable content, filtering, or user-generated length, it is the strongest single argument for moving your simple animations to CSS.
Where it stops
This is the real body of the article. Every item here is something we ship regularly.
Smoothed scrub
Covered above. No CSS counterpart.
Real pinning
position: sticky is not pinning. ScrollTrigger's pin: true takes the element out of flow, holds it, injects spacer height so the page below does not jump, and restores it at the end.
ScrollTrigger.create({
trigger: '.panels',
pin: true,
scrub: true,
end: () => '+=' + document.querySelector('.panels').offsetWidth
})
Horizontal scroll sections, pinned storytelling, anything where pin duration is computed from measured content. Simple cases approximate with sticky and a tall parent. pinSpacing, nested pins and measured durations do not.
Orchestration
A CSS keyframe animation drives one element. A GSAP timeline sequences many, with labels, relative offsets, nesting and stagger.
const tl = gsap.timeline({ scrollTrigger: { trigger: '.hero', scrub: true } })
tl.to('.title', { y: -100 })
.to('.subtitle', { opacity: 0 }, '-=0.3')
.from('.cards', { y: 60, stagger: 0.08 }, 'cards')
.to('.bg', { scale: 1.2 }, 'cards')
You can hand-roll this by giving every element the same named timeline and different animation-range values. It works. It is also a spreadsheet of magic numbers, and when a designer asks to shift one beat by 200ms you are recalculating all of them. For a solo experiment that is fine. For a team where someone else maintains it next year, it is not.
Progress in JavaScript
A shader uniform is not a CSS property. If scroll progress has to drive a canvas, a video scrub or an analytics event, you need the number in JavaScript.
ScrollTrigger.create({
trigger: '.section',
onUpdate: self => uniforms.uProgress.value = self.progress
})
There is a ScrollTimeline JavaScript API you can pass to element.animate(), but MDN lists it as limited availability, and it hands you an animation timeline rather than an arbitrary per-frame callback.
If you run WebGL this is the biggest item on your list by some distance, and it has a wrinkle that surprised me. It gets its own section at the end.
Responsive scene logic
ScrollTrigger.matchMedia() lets you build structurally different scenes per breakpoint with correct teardown. CSS media queries get you different values, not different structures.
The plugin surface
SplitText for per-character reveals, MorphSVG, DrawSVG, MotionPath with real control. No CSS equivalent, and these are precisely what an art director asks for by name.
Worth noting: GSAP has been 100% free since April 2025, every former Club plugin included, commercial use included, after Webflow acquired GreenSock. Cost is no longer an argument for leaving. Only bundle size and main-thread work are, and those are real but they are a tradeoff rather than a verdict.
If you use Lenis, half of this changes
I almost published without this section, and it would have been wrong.
Lenis is on most of the sites we build, and it changes the scrub answer completely. To see why, you have to know how it works, because a lot of people assume it is doing what Locomotive v4 used to do.
Old smooth scroll libraries faked it. They fixed a wrapper and translated it every frame. Under that approach, native scroll position never actually changes, so animation-timeline: scroll() sees nothing and your CSS scroll animations simply never run.
Lenis is not that. It runs on top of the native scroll engine, as a small animation layer built on scrollTo, with no transforms painted per frame, in under 4 KB. It intercepts the input, interpolates toward the target, and then sets the real scroll position each frame.
That single design decision is the whole story. The document's actual scroll offset updates, just smoothly. And animation-timeline reads the actual scroll offset.
So with Lenis on the page, your CSS scroll-driven animations inherit the smoothing for free. No configuration, no wiring, no lenis.on('scroll', ...). The lag I said CSS could not do arrives through the scroll position itself.
That is a genuinely good deal. Compare the integration cost:
// GSAP with Lenis: you wire it up
lenis.on('scroll', ScrollTrigger.update)
gsap.ticker.add((time) => lenis.raf(time * 1000))
gsap.ticker.lagSmoothing(0)
/* CSS with Lenis: nothing */
.reveal {
animation: fade-up linear both;
animation-timeline: view();
}
The CSS version has no integration code because there is nothing to sync. Both are reading the same number from the same place.
Four caveats before you get excited, because I did and then thought about it.
The smoothing is global, not per animation. Lenis has one lerp value for the entire page. ScrollTrigger's scrub is per instance, so you can have a hero lag by a full second and a progress bar track instantly. With Lenis everything shares one feel. For most sites that is fine, and arguably more coherent. For a hero where one layer should drag and another should stick to the cursor, it is not enough.
Do not double-smooth. Lenis plus scrub: 1 is a lerp on top of a lerp. It feels mushy and disconnected, and it is the most common mistake I see in this combination. If Lenis is running, use scrub: true and let Lenis own the feel.
You lose the compositor argument. This is the honest cost. One of the best things about CSS scroll-driven animations is that they run off the main thread. Lenis drives scroll from a main-thread rAF loop, so the position updates are now gated by main-thread frames. The animation still avoids the class of jank that comes from JavaScript touching style on every scroll event, but "this runs on the compositor and your JS cannot block it" is no longer true. If off-main-thread smoothness was your reason for moving to CSS, adding Lenis takes back part of what you bought.
Reduced motion removes the effect. Lenis honors prefers-reduced-motion by default and forces lerp to 1, so scroll tracks the input device one to one. That is correct behaviour, and it means users with that preference get the raw unsmoothed version of your animation. Do not build a scroll effect that only reads correctly with the lag.
The upshot: if Lenis is already in your stack, the case for CSS on simple scroll work gets meaningfully stronger, because you were paying for the smoothing anyway and CSS gets it for nothing. If it is not, adding a library to make your zero-byte solution feel right is a trade you should make deliberately rather than by habit.
Where support actually stands
Chrome and Edge shipped it unflagged in 115, July 2023. Three years of runway.
Safari landed it in Safari 26 in September 2025, added threaded scroll-driven animations in 26.4, and fixed progress-accuracy bugs in 26.5 in June 2026.
Firefox is still behind the layout.css.scroll-driven-animations.enabled flag in stable as of Firefox 152, June 2026. On by default in Nightly, and a named Interop 2026 priority, so it is coming.
Around 82% globally, which means not Baseline, blocked by Firefox.
I ship it anyway because the failure mode is benign. An unsupported browser ignores animation-timeline and the element sits at its natural state. Write it so that natural state is the visible one:
.reveal {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
Visible by default, animated as enhancement. Compare that to a library failing to initialise, which usually means content stuck at opacity: 0 and a client on the phone.
Run the audit yourself
Here is the version of this you can actually apply, and it is not a list of my conclusions.
Open a real project. List every scroll animation in it. For each one, mark it green if CSS has a direct equivalent, yellow if you could get close with named timelines and some magic numbers, red if there is no equivalent at all. Reveals, parallax and progress bars will be green. Anything driving a WebGL uniform will be red.
Then look only at the red column. That column is the decision. If it is empty, you have a genuine migration and a smaller bundle. If it has three items, the library stays and the honest question becomes a different one: is CSS worth adopting alongside something I am keeping anyway?
For me the answer is yes, and for reasons the bundle-size framing misses entirely.
-
Reveals, parallax, progress bars: CSS. Wrapped in
@supports, visible by default. Most animation work by volume, and it deletes a category of init and teardown bug. - Sections with dynamic height: CSS whenever possible. No refresh calls, no observers, nothing to invalidate.
- Pinned sequences, horizontal scroll, orchestrated heroes: GSAP. No hesitation.
- Anything driving WebGL or canvas: GSAP, and everything coupled to it too. The uniform needs the number in JS, and any DOM element that must stay locked to the scene has to come off the same clock.
- Never both on one element. One owner per animation or you will lose an afternoon to a fight you cannot see in DevTools.
- CSS first when prototyping. No install, no init, no cleanup, edit in DevTools and see it immediately. I now rough out scroll ideas in a stylesheet before deciding whether the finished version needs a timeline. Half the time it does not, and the other half I already know exactly what I am asking GSAP to do.
That is not a migration. It is a second tool, and it is a better one for a large share of the work.
If you run WebGL, one entry in that red column is different
I left this until now because it does not apply to everyone. If it applies to you, it is probably the most important section here.
Pure WebGL is easy to reason about. It is all JavaScript, so you write JavaScript. The trouble starts when a DOM element has to move with the WebGL. A heading locked to a distorted image plane. A caption sitting on a mesh as it scrolls. Text revealing in step with a shader wipe. This comes up constantly, partly because you usually want the text to stay real DOM for accessibility and SEO rather than baking it into the canvas.
Now two things have to agree, and here is the twist: the compositor advantage becomes the problem.
That was the first item on my list of reasons to like CSS. Scroll-driven animations are updated by the compositor, deliberately independent of your main thread. Your WebGL renders in a main-thread rAF. Those are two different clocks. On a given frame the CSS-animated heading can reflect one scroll position while the rendered WebGL frame reflects a slightly older one. The offset is a fraction of a frame. It is invisible in a screenshot and glaring in motion. The text swims against the image.
It reads as cheap, which on this kind of project is the single thing you were paid to avoid.
Off the main thread means on a different clock. Everywhere else that is the feature. Here it is the bug.
This is not hypothetical, it is the reason Lenis exists at all. It began as an internal tool for syncing WebGL and the DOM, and its own pitch is that jumps and delays in scroll-linked animations come from browsers running effects asynchronously from native scroll.
So the rule is about coupling, not co-presence:
- A DOM element that must stay locked to WebGL: drive both from the same JS loop. Same rAF, same progress value, one source of truth. No CSS scroll-driven animation on that element, ever.
- A DOM element that merely shares a page with WebGL: CSS is fine. A nav shrinking, an unrelated section fading in, a progress bar. Nobody perceives sub-frame desync between two things that are not visually related.
Get that line wrong and you produce the worst class of bug in this kind of work. Nothing breaks. Nothing errors. It just feels slightly wrong, and nobody on the call can articulate why.
Which is a decent argument for the whole thesis, actually. The snippet at the top of this article would have worked perfectly in that project, on that page, next to a WebGL scene it was quietly fighting with.
The part that generalises
None of the above is really about animation.
The pattern repeats for every stack decision I have made in fifteen years. Someone posts the snippet, the snippet is real, and the snippet is the easiest 80% of a problem whose difficulty lives entirely in the other 20%. Vue to Nuxt, REST to GraphQL, SCSS to Tailwind, npm to pnpm. The demo is always the happy path, because a demo of the hard path would not be a demo.
Stack decisions are not made on the best case. They are made on the worst thing you currently do that the new thing cannot do, and on whether keeping both is cheaper than keeping either.
Zero bytes is a great headline. It is not a feature inventory. I would rather ship 35 KB and the animation the client approved.
Sources: MDN on CSS scroll-driven animations and ScrollTimeline, the Lenis repo, and Webflow's GSAP announcement. Support figures accurate at time of writing. Check caniuse before shipping, this one is moving.
Top comments (0)