Scroll-driven animations with Tailwind CSS: animating on scroll without JavaScript
“Effects with Tailwind” series · 3 of 4
- Build an animated border with Tailwind CSS
- Glassmorphism without wrecking legibility
- Scroll-driven animations ← you are here
- View transitions in Astro
For years, animating something based on scroll meant using JavaScript: an IntersectionObserver, a scroll listener or straight up a 30 KB library. Today the browser does it on its own, and on the compositor instead of the main thread, so scrolling doesn’t get heavy and there’s no code to maintain.
They’re called scroll-driven animations, and underneath they’re ordinary @keyframes that advance on a different clock.
At the end I describe a problem I ran into on this very blog: the effect worked in development and in production it stopped working without any warning. The culprit was the CSS minifier, and it can happen to anyone using Astro or Vite.
The idea: giving the animation a different clock
A normal CSS animation advances with time; a scroll-driven animation advances with scroll. The only change is the animation-timeline property, which has two variants:
scroll(): progress is how far the container has scrolled, from 0% at the top to 100% at the bottom. Good for progress bars.view(): progress is the element’s journey as it crosses the screen. Good for making things appear as they enter.
With scroll() the clock is the container, and with view() it’s the element itself. Almost everything you’ll want to do comes down to picking the right one.
How to declare it in Tailwind 4
There’s a decision here worth explaining, because it’s what breaks most often.
The @keyframes go in @theme, inside your global.css, like any other Tailwind animation:
@theme {
@keyframes reveal-up {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes grow-x {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
}
The natural thing would be to declare --animate-reveal-up and then add the timeline with an arbitrary class, like this:
<!-- ⚠️ Fragile: don't do this -->
<div class="animate-reveal-up [animation-timeline:view()]"></div>
The problem is that the animation shorthand resets animation-timeline to auto. If Tailwind writes the shorthand class after the timeline one, the animation loses its scroll clock and advances with time, or doesn’t advance at all. And you don’t control that order.
The safe way is to put both in a single utility with @utility, so the order stops mattering:
@utility reveal-on-scroll {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
@utility scroll-progress {
animation: grow-x linear;
animation-timeline: scroll(nearest);
transform-origin: left center;
}
A detail that saves time: @utility can’t be nested. If you put it inside a @supports, Tailwind stops the build with `@utility` cannot be nested. It has to be the other way round, with the @supports inside the utility; we’ll see that further down.
Notice also that there’s no animation-duration. With a scroll timeline the right duration is auto, which means “take up the whole timeline”. Setting it to 2s makes no sense.
Reading progress bar
It’s the most practical use on a blog. It takes scroll() and two elements:
<div class="h-64 overflow-y-auto rounded-xl border border-mint-300/40">
<div class="sticky top-0 h-1.5 w-full origin-left bg-mint-400 scroll-progress"></div>
<!-- tall content -->
</div>
Scroll inside the box and watch the green bar at the top:
Keep scrolling…
The bar at the top uses no JavaScript: its progress is your scroll.
No IntersectionObserver, no listeners, no requestAnimationFrame.
It runs on the compositor, so it doesn't compete with your JavaScript.
Almost there.
100%. That's animation-timeline: scroll().
To apply it to the whole page instead of a box, use the same element with fixed top-0 and scroll() without an argument, and the clock becomes the document’s scroll.
Appearing as they enter the screen
This is where view() comes in, along with the property that lets you tune the effect: animation-range.
animation-range: entry 0% cover 45% reads like this: start when the element peeks in (entry 0%) and finish when it has covered 45% of its path across the screen. Without that range, the animation is spread over the whole journey and the element takes ages to appear.
<article class="reveal-on-scroll">I appear as I enter</article>
Scroll slowly and watch each card.
First card
Second card
Third card
Fourth card
None of them knows the others exist. There's no orchestration.
There are no staggered delays or indexes. Each element animates based on its own position, so the cascade effect happens on its own, because they’re at different heights.
The production problem: the minifier
This one took me a while to find.
Astro and Vite minify CSS with lightningcss by default, and lightningcss does an optimization that breaks everything here: it folds animation-timeline into the animation shorthand. My utility came out of the build like this:
/* what the build produces */
.reveal-on-scroll{animation:linear both reveal-up view()}
It looks reasonable, but the animation shorthand doesn’t accept a timeline value, so the browser treats the declaration as invalid and drops the whole rule. There’s no warning and no error in the console: nothing happens.
I checked it by injecting both forms in Chrome 148 and reading what survived the parser:
| Form | Result |
|---|---|
animation:linear both reveal-up view() |
The whole rule is dropped |
animation:reveal-up linear both; animation-timeline:view() |
Parses fine, with animation-duration: auto |
And it works in development, because nothing gets minified there. It only breaks when you deploy.
The fix is to change the CSS minifier in astro.config.mjs:
export default defineConfig({
vite: {
plugins: [tailwindcss()],
build: {
cssMinify: "esbuild",
},
},
});
esbuild doesn’t do that merge and the output stays valid:
.reveal-on-scroll{animation:linear both reveal-up;animation-timeline:view();animation-range:entry cover 45%}
What does it cost? I measured it on this blog: 123 more bytes of compressed CSS, 0.9%. That seems like a very small price for the effect to work.
Accessibility
A scroll-linked animation can cause dizziness or disorientation for people with vestibular disorders. Respecting prefers-reduced-motion is what WCAG criterion 2.3.3 asks for. It’s level AAA, so it isn’t required in an AA audit, but it costs very little to meet.
In Tailwind it’s a variant:
<div class="reveal-on-scroll motion-reduce:animate-none"></div>
Or better, handle it once in your global.css so you don’t have to remember it on every element:
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll,
.scroll-progress {
animation: none;
opacity: 1;
transform: none;
}
}
Watch the opacity: 1: if your keyframe starts at opacity: 0 and you only remove the animation, the element stays invisible forever. It’s an easy mistake to make, and the result is content that’s inaccessible precisely for the person who asked for less motion.
Browsers without support
In a browser without support, an element that starts at opacity: 0 never appears. That’s why the default state has to be the visible one, and the animation only kicks in if the browser supports it.
Since @utility can’t be nested, the @supports goes inside:
@utility reveal-on-scroll {
@supports (animation-timeline: view()) {
animation: reveal-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
}
Which compiles to this:
@supports (animation-timeline:view()){
.reveal-on-scroll{animation:linear both reveal-up;animation-timeline:view();animation-range:entry cover 45%}
}
You can check current support on Can I use. The rule is simple: if the effect doesn’t load, the content still has to be there.
The last part of the series animates the move between pages: view transitions in Astro.

