Tailwind Scroll Animations: @starting-style and animation-timeline
Master scroll-driven animations in Tailwind v4 using @starting-style and animation-timeline. No JS libs needed — pure CSS that ships in 2026.
Why Scroll Animations Changed in 2025–2026
For years, scroll animations meant one thing: reach for Framer Motion, GSAP, or at minimum an IntersectionObserver hack duct-taped to a useState. That's not necessarily wrong — those tools are good — but the browser caught up fast. As of Chrome 115 and the 2025 CSS Scroll Animations spec landing in Safari 18.2, you now get animation-timeline natively, no polyfill, no bundle bloat.
Tailwind v4 shipped its CSS-first config in early 2025, and with it, first-class support for arbitrary CSS properties via [animation-timeline:scroll()] utilities. That means you can wire up scroll-driven effects without touching a single JavaScript file. Wild, right?
Worth noting: this isn't just about flair. Scroll animations that run on the compositor thread (which native animation-timeline does) won't jank even on a mid-range Android device. A JS-driven scroll listener absolutely will if you're not careful with throttling and will-change.
In practice, @starting-style solves a completely different but equally annoying problem: enter animations on elements that are inserted into the DOM or that transition from display: none. Before this, you'd need a 1-frame JS delay to trigger a CSS transition. Now you don't.
So this article covers both. They're related in spirit — modern CSS doing what you used to need a library for — even though they're separate specs. Let's get into it.
animation-timeline: scroll() — The Basics
The core idea: instead of a time-based animation, you bind progress to scroll position. When the user scrolls from 0px to, say, 400px, your animation goes from 0% to 100%. The browser handles the interpolation. You just write the keyframes.
Here's the minimal version with a scroll progress indicator — the classic example everyone does first:
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
width: 100%;
transform-origin: left;
background: linear-gradient(90deg, #6366f1, #ec4899);
animation: grow-bar linear;
animation-timeline: scroll(root);
}In Tailwind v4, you'd pull that into your component with arbitrary utilities. The animation-timeline property isn't in the default utility set yet (as of Tailwind v4.1), so you use bracket syntax:
<div
class="fixed top-0 left-0 h-1 w-full origin-left
bg-gradient-to-r from-indigo-500 to-pink-500
[animation:grow-bar_linear]
[animation-timeline:scroll(root)]"
></div>One more thing — scroll() takes two optional arguments: the scroller (root, nearest, or a named scroll-timeline-name) and the axis (block, inline, x, y). For most vertical layouts you'll use scroll(root block) or just scroll(root) since block is the default.
View Timelines: Animating as Elements Enter the Viewport
Scroll progress bars are the "hello world" of this API. The part that actually replaces IntersectionObserver is animation-timeline: view(). This ties animation progress to how much of an element is visible in the viewport — 0% when it's fully offscreen, 100% when it's fully in view.
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fade-up ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}That animation-range line is key. Without it, the animation runs across the full scroll journey of the element — meaning it starts animating the moment the element's top edge enters the scroll container and doesn't finish until the bottom edge exits. entry 0% entry 40% restricts it to the first 40% of the entry phase, which feels like a snappy reveal rather than a slow 300px crawl.
In Tailwind, wiring this up looks like:
<article
class="rounded-xl p-6 bg-white shadow-md
[animation:fade-up_ease-out_both]
[animation-timeline:view()]
[animation-range:entry_0%_entry_40%]"
>
...
</article>Honestly, this is genuinely nicer than keeping an IntersectionObserver alive for every card on a marketing page. If you've built anything with Empire UI's component library, you can drop these classes directly onto cards and sections — no extra JS setup, no ref juggling.
@starting-style: Enter Animations Without JS
@starting-style landed in all major browsers by mid-2025. The problem it fixes: when you set an element to display: none and then flip it to display: block, CSS transitions don't fire because the element had no "previous" style state to transition from. So you'd either use JS to add a class on the next frame, or reach for something like Framer Motion's AnimatePresence.
Now you can write this instead:
.dialog {
opacity: 1;
transform: scale(1);
transition: opacity 200ms ease, transform 200ms ease;
}
@starting-style {
.dialog {
opacity: 0;
transform: scale(0.95);
}
}When .dialog is first painted — coming out of display: none or inserted into the DOM — the browser treats @starting-style as its "before" state and transitions from there. The result is a smooth scale-up with zero JavaScript. It also plays nicely with the CSS @layer cascade, so you can define starting styles inside component layers without specificity fights.
In a Tailwind project you'll write this in a @layer components block or in a global CSS file. There's no Tailwind utility for @starting-style itself — it's an at-rule, not a property. That's fine. Tailwind v4's CSS config system encourages co-locating this kind of thing in your stylesheet:
/* globals.css */
@layer components {
.modal-panel {
@apply opacity-100 scale-100;
transition: opacity 180ms ease, transform 180ms ease;
}
@starting-style {
.modal-panel {
@apply opacity-0 scale-95;
}
}
}Quick aside: @starting-style only covers enter animations. Exit animations (when going back to display: none) still require either JS class toggling or the transition-behavior: allow-discrete property — which is a whole other rabbit hole worth knowing about.
Combining Both: Scroll-Reveal With Clean Enter Animations
Here's where it gets interesting. You can stack animation-timeline: view() for scroll-triggered reveals with @starting-style for dialog/modal enters, and the two don't interfere at all — they're completely independent mechanisms. Use each where it fits.
A pattern I keep coming back to: use view() timelines for content sections (feature grids, testimonials, card lists) and @starting-style for interactive UI that pops in on user action (modals, toasts, dropdown panels). Both give you that polished feel without a dependency.
If you're building something like the glassmorphism components in Empire UI — where panels fade in with a blur backdrop — @starting-style is a perfect fit for the initial mount animation. Pair it with backdrop-filter: blur(12px) and a subtle transform: translateY(8px) starting position and you've got a modal that feels expensive to build but costs you maybe 10 lines of CSS.
@layer components {
.glass-modal {
@apply opacity-100 translate-y-0 backdrop-blur-md;
background: rgba(255,255,255,0.12);
border: 1px solid rgba(255,255,255,0.2);
transition:
opacity 220ms ease,
transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
@starting-style {
.glass-modal {
@apply opacity-0 translate-y-2;
}
}
}That cubic-bezier is the classic "spring-ish" curve — (0.34, 1.56, 0.64, 1) — which gives a slight overshoot. At 220ms it's snappy without being twitchy. Worth experimenting with the gradient generator if you're customizing the background, since getting the gradient right on a glass surface makes a big difference.
Browser Support and Fallback Strategy
animation-timeline with scroll() and view() reached full Baseline status in late 2025 — Chrome 115+, Firefox 110+, Safari 18.2+. In practice, that covers the vast majority of users you're shipping to right now. But if you need to support older Safari (pre-18) or the occasional Firefox ESR user, you need a fallback.
The cleanest approach: feature-detect with @supports and provide a static fallback state for browsers that don't support the timeline API:
.card {
/* Fallback: always visible, no animation */
opacity: 1;
transform: translateY(0);
}
@supports (animation-timeline: scroll()) {
.card {
animation: fade-up ease-out both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}For @starting-style, browser support is actually slightly better — it landed in Chrome 117, Firefox 129, and Safari 17.5. So you can often skip the @supports guard there if your users are broadly on modern browsers.
Look, the progressive enhancement story here is solid. An older browser sees static content. A modern browser sees polished scroll reveals. Nobody's locked out of your content, and you're not shipping 40KB of animation library to handle what CSS now does natively. That's a win you can ship with confidence.
FAQ
You can use animation-timeline in Tailwind v3 via arbitrary properties like [animation-timeline:scroll()]. Tailwind v4 just makes the CSS-first config cleaner — neither version ships the utility by default.
They're independent — Framer Motion sets inline styles via JS while animation-timeline runs through the CSS engine. You can use both in the same project without conflicts, though there's no reason to animate the same element with both at once.
Yes. Every time the element goes from not-rendered (display:none or unmounted) to rendered, @starting-style applies again. That's the behavior you want for repeated modal opens.
It's actually better. The browser runs scroll-driven animations on the compositor thread in most cases, so they don't block the main thread. A JS scroll listener firing every pixel definitely can.