Scroll-Driven Animations in Tailwind v4 Without a JS Library
animation-timeline replaces the scroll-animation library category — behind two guards. The Tailwind v4 setup, the element you must never fade in, and when IntersectionObserver still wins.
CSS scroll-driven animations replace the whole scroll-animation library category with two properties: animation-timeline: view() ties a keyframe animation to an element's visibility, and animation-timeline: scroll() ties it to a scroll container's position. They run off the main thread, need no JavaScript, and degrade to "content is simply visible" in browsers that lack them.
The catch in mid-2026 is Firefox, which still has the feature behind a flag in stable. This post covers the Tailwind v4 setup, the @supports and prefers-reduced-motion guards that make it safe to ship today, the one element you must never fade in, and when a nine-line IntersectionObserver is still the better answer.
Support, honestly stated
| Browser | Status (mid-2026) |
|---|---|
| Chrome / Edge | Shipped unflagged since 115 (July 2023) |
| Safari | Shipped in Safari 26 (September 2025); threaded in 26.4 |
| Firefox | Behind layout.css.scroll-driven-animations.enabled in stable; on by default in Nightly; an Interop 2026 priority |
| Global coverage | Roughly 84% |
So: not Baseline, and the gap is one browser that is visibly working on it. That is a fine place to ship a progressive enhancement and a bad place to ship a dependency. The distinction is the whole design of what follows — the page must be complete and correct for the other 16%, with the animation as decoration on top.
The two timelines
view() — the animation is driven by how far the element itself has travelled through the viewport. This is the "fade in as it scrolls into view" effect, which is 90% of what scroll-animation libraries are used for.
scroll() — the animation is driven by a scroll container's own progress, independent of any one element. This is the reading-progress bar at the top of an article, or a parallax layer.
Plain CSS first, before any Tailwind:
@keyframes reveal {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
.reveal {
animation: reveal linear both;
animation-timeline: view();
/* Start when the element's top enters the viewport; finish 40% up. */
animation-range: entry 0% cover 40%;
}
animation-range is where the feel lives. entry 0% is the moment the element's leading edge crosses into the viewport; cover 40% is 40% of the way through its total pass. Shorten the range for a snappier reveal, lengthen it for a slower one. animation-timing-function: linear is intentional — the scroll position is already the easing.
And the progress bar, which is three lines and used to be a scroll listener:
.progress {
transform-origin: 0 50%;
animation: grow linear both;
animation-timeline: scroll(root block);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
Wiring it into Tailwind v4
Tailwind v4 is CSS-first, so this belongs in globals.css rather than a plugin. Keyframes and animation shorthands go in @theme; the timeline utilities are custom, so they use the v4 @utility directive:
@import "tailwindcss";
@theme {
--animate-reveal: reveal linear both;
@keyframes reveal {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: none;
}
}
}
@utility timeline-view {
animation-timeline: view();
}
@utility range-entry {
animation-range: entry 0% cover 40%;
}
--animate-reveal generates an animate-reveal utility. Combined:
<section class="animate-reveal timeline-view range-entry">…</section>
Two Tailwind-specific notes. Editing @theme does not hot-reload under Turbopack — restart the dev server or your new utility silently does not exist. And Tailwind ships motion-safe: and motion-reduce: variants, which matter in the next section.
The two guards that make this shippable
Guard one: never start at opacity: 0 without @supports
This is the bug that turns a nice enhancement into a blank page. If a browser does not understand animation-timeline, it still understands your @keyframes — and with animation-fill-mode: both it will happily apply the from state and leave the element invisible forever, because nothing is driving the timeline.
Gate the hidden state on support for the thing that reveals it:
@supports (animation-timeline: view()) {
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
Outside the @supports block, the element has no animation and no opacity rule. In Firefox stable today, the section is simply there. That is the correct fallback: content, immediately, with no motion.
Test it by disabling the flag rather than by trusting the CSS. One toggle in Firefox tells you whether your page still works.
Guard two: respect prefers-reduced-motion
Scroll-linked motion is a common vestibular-disorder trigger, and this is not a nicety — it is the WCAG 2.3.3 (Animation from Interactions) case almost exactly. Wrap the whole thing:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.reveal { /* … */ }
}
}
Or in Tailwind's utility form, where the variant does the same job:
<section class="motion-safe:animate-reveal motion-safe:timeline-view">…</section>
motion-safe: is the right variant here rather than motion-reduce: removing it afterwards, because it means the default — the state you get if the media query fails to match for any reason — is no animation.
Never animate the LCP element
Your hero headline is almost certainly your Largest Contentful Paint element. Fading it in on scroll does three bad things:
- It delays LCP. An element at
opacity: 0does not count as painted. You have moved your largest paint from "immediately" to "whenever the animation progresses", and Lighthouse will report exactly that. - It is pointless. The hero is already in the viewport on load. There is no scroll to drive the timeline, so it either appears instantly anyway or, worse, sits at its
fromstate. - It breaks for the 16%. See guard one.
The rule that follows: the first viewport gets no scroll-driven animation. Reveal starts at the second section. If the hero needs motion, use a plain time-based @keyframes on load with a short duration, and start from a small translateY at full opacity rather than from invisible.
The same reasoning bans animating layout properties. Animate transform and opacity, which the compositor handles. Animating height, margin or top forces layout on every frame and, if it happens as content scrolls into view, contributes real Cumulative Layout Shift.
When to still reach for JavaScript
Scroll-driven CSS does not do everything. IntersectionObserver remains the right tool when:
- The reveal must happen once and stay.
view()is a timeline, so scrolling back up scrolls the animation back. That is correct for a progress bar and often wrong for a content reveal. - You need to trigger something that is not an animation — lazy-loading, an analytics event, a counter.
- You need Firefox stable parity today and the motion is part of the design rather than decoration.
The observer version is small enough that "avoiding a dependency" was never the real argument for CSS:
"use client";
import { useEffect, useRef } from "react";
export function Reveal({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.dataset.shown = "true";
io.disconnect(); // once, and stay
}
},
{ rootMargin: "0px 0px -15% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
return <div ref={ref} data-shown="false" className="reveal-js">{children}</div>;
}
With the CSS keyed off the attribute, and — the same guard as before — the hidden state applied only once JavaScript has marked the element as under its control:
.reveal-js[data-shown] { transition: opacity .5s, transform .5s; }
.reveal-js[data-shown="false"] { opacity: 0; transform: translateY(24px); }
If that attribute is never set, because JavaScript failed or never ran, the content is visible. Same principle, different mechanism.
The real cost of the JS version is not bytes, it is that every animated section becomes a Client Component. On a page that is otherwise entirely server-rendered, that is a meaningful architectural concession for a fade.
Mistakes and their symptoms
| Mistake | Symptom | Fix |
|---|---|---|
No @supports guard | Blank sections in Firefox stable | Put the hidden state inside @supports |
| Fading in the hero | LCP regression in Lighthouse | No scroll animation in the first viewport |
Animating height / margin | Layout shift, janky frames | transform and opacity only |
No prefers-reduced-motion guard | Accessibility failure | motion-safe: or the media query |
| Expecting a one-shot reveal | Animation reverses on scroll up | IntersectionObserver with disconnect() |
Editing @theme, not restarting | Utility silently missing | Restart the Turbopack dev server |
animation-timeline on the wrong element | Nothing happens | It goes on the animated element, not the parent |
| Range too long | Animation "never finishes" | Tighten animation-range |
| Reveal on every section | Page feels sluggish to read | Reserve it for two or three moments |
That last row is the one no compatibility table will warn you about. Motion is punctuation. A page where every block fades in reads slower than one where nothing does, because the reader is repeatedly waiting on content that is already downloaded.
Frequently asked questions
Can I use scroll-driven animations in production right now?
Yes, as an enhancement, behind @supports and prefers-reduced-motion. No, as a mechanism the layout depends on — roughly one in six visitors will not run it.
Do these run on the compositor?
In Chromium and in Safari 26.4+, yes, for compositable properties like transform and opacity. That is the main performance argument over a scroll listener, which runs your code on the main thread on every frame.
Does this work with Framer Motion or GSAP? It replaces the common case rather than competing with them. Keep a library for orchestrated sequences, SVG path work, and physics; use CSS for reveals and progress bars, and you will often find the library is no longer earning its bundle.
How do I animate a horizontal carousel's progress?
animation-timeline: scroll(self inline) on a scroll snap container. The inline axis is the horizontal one in a left-to-right writing mode, and using the logical keyword keeps it correct in RTL.
Is animation-timeline supported in Tailwind out of the box?
Not as a first-party utility as of v4. Define your own with @utility, as above — one small block in globals.css, and no plugin.
Templates where the motion is already tuned
Getting reveals right is less about the CSS than about restraint and about the two guards, and the guards are exactly what gets skipped when a launch date is close.
Our Next.js landing templates ship the motion already scoped to the sections that benefit. ASoc Chain is a DeFi-protocol site built around an on-chain 3D hero visual; ASoc Canvas is a no-code page-builder site with a drag-and-drop editor showcase; ASoc Frame is an AI image-generator page with a prompt hero and a how-it-works flow.
See all Tailwind landing page templates, or the Next.js landing page templates.
