EmpireUI
Get Pro
← Blog8 min read#tailwind#scroll#animation

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.

Code editor showing CSS animation-timeline scroll properties on dark screen

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

Do I need Tailwind v4 specifically, or does this work with v3?

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.

Does animation-timeline work with Framer Motion or do they conflict?

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.

Will @starting-style fire again if the element is hidden and reshown?

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.

Is animation-timeline bad for performance compared to JS scroll listeners?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

tailwindcss-motion Plugin: Declarative Animations in Tailwindtailwindcss-animate Plugin: Fade, Slide, Scale Entry AnimationsCSS @starting-style: Entry Animations Without JS ClassesCSS Scroll Snap: Precise Scrolling Sections Without JavaScript