Triggering CSS Animations on Scroll With No JavaScript
Create CSS scroll-triggered animations without JavaScript. See syntax, trigger actions, scroll boundaries, and progressive enhancement with an IntersectionObserver fallback.
Scroll-triggered animations let scrolling decide when a normal CSS animation begins. Once an element reaches a set scroll position, the animation plays on its own clock. You declare them with the timeline-trigger and animation-trigger properties, and no IntersectionObserver is needed.
Most “fade in on scroll” code looks the same everywhere: an observer, a threshold, a class toggle, and an unobserve() call that someone forgets to add. That work can now be done in CSS.
This article covers the syntax, one complete reveal example, the three trigger behaviours, how to pick scroll boundaries, and how to ship the feature safely in a Chromium-only world.
Key Takeaways
- A scroll-triggered animation needs two declarations.
timeline-triggernames a trigger and sets the scroll range that activates it.animation-triggertells an animation which trigger to follow and what to do when it fires. - The range
entry 100% exit 0%activates a trigger only while the element is fully inside the scrollport. Chrome’s documentation treats it as equivalent to thecontainkeyword. play-forwards play-backwardsplays the animation on entry and reverses it on exit.play-once, from the Animation Triggers draft, runs it a single time.- A triggered animation keeps its own duration and easing, so scrolling faster does not make it play faster.
- Chrome’s support data lists only Chromium browsers for scroll-triggered animations, so hide content inside
@supportsand keep an IntersectionObserver fallback.
What Is the Difference Between Scroll-Driven and Scroll-Triggered Animations?
Scroll-driven animations tie an animation’s progress to scroll position. Scroll-triggered animations use scrolling only to decide when an ordinary animation starts. Our guide on how to create scroll animations with just CSS covers the first model: animation-timeline with scroll() and view(). There, the timeline replaces the clock, so scrolling back plays the animation in reverse. This article covers timeline-trigger and animation-trigger, which start a regular animation, with its own duration, once the element reaches a chosen scroll range.
If you want a reading-progress bar or parallax, use CSS scroll progress animations. If you want a card to fade in over 400ms once it is visible, use a trigger. In its Google I/O 2026 Web UI roundup, Chrome lists the two as separate features and describes triggers as a way to replace IntersectionObserver with CSS. Put simply, a trigger is the CSS version of “add a class when this element becomes visible.”
A Scroll-Triggered Fade-In Example
A scroll-triggered fade-in needs three parts: a @keyframes rule, an animation with forwards fill, and a trigger linking the animation to a view timeline. The Chrome announcement of scroll-triggered animations defines the syntax. Its main points are that the trigger and the animation can sit on different elements, and that a trigger uses a scroll or view progress timeline as its source.
<section class="reveal">
<div class="reveal-content">
<h2>Shipping notes</h2>
<p>This block fades and slides in once it is fully on screen.</p>
</div>
</section>
@keyframes reveal {
to {
opacity: 1;
translate: 0 0;
}
}
@supports (timeline-trigger-name: --t) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
timeline-trigger: --reveal view() entry 100% exit 0%;
trigger-scope: --reveal;
}
.reveal-content {
opacity: 0;
translate: 0 2rem;
animation: reveal 400ms ease-out forwards;
animation-trigger: --reveal play-forwards;
}
}
}
How it works:
- The trigger sits on the section and the motion on its child. The element that slides is not the element whose visibility is measured, so the
translateoffset cannot move the trigger boundary. entry 100% exit 0%activates the trigger once the section is fully inside the scrollport.play-forwardsstarts the animation each time the trigger activates.forwardsfill holds the final keyframe afterwards.- The hidden start state lives inside the feature query. Browsers without support never apply
opacity: 0, so content stays visible. trigger-scopekeeps each section’s--revealname local. By default any element on the page can see a trigger name, the same wayanchor-nameworks, as the Chrome 146 release notes describe.
With play-forwards alone, leaving the range does nothing: the element keeps its end state as you scroll away. When it becomes fully visible again, the trigger activates again and the animation replays. The next section shows how to change that.
Choosing a Trigger Action
animation-trigger accepts a trigger name followed by up to two actions. The first runs when the trigger activates and the second runs when it deactivates. The action you pick decides what happens on re-entry and on exit.
| Desired behaviour | animation-trigger value | Note |
|---|---|---|
| Replay on every re-entry | --reveal play-forwards | Can visibly jump back to the start state |
| Reverse on exit | --reveal play-forwards play-backwards | After reversing, the element shows its base styles |
| Run once, then stay | --reveal play-once | Draft keyword, may change |
animation-trigger: --reveal play-forwards play-backwards; /* reverses on exit */
animation-trigger: --reveal play-once; /* runs once; Animation Triggers draft keyword */
forwards fill holds the to keyframe after play-forwards finishes. It does not hold anything after play-backwards finishes. At that point the animation stops applying and the element falls back to its base styles. In this example the base styles are the hidden start state (opacity: 0; translate: 0 2rem), because the keyframes define only to, so the element still ends up hidden. If your keyframes set a from state that differs from the base styles, use both so that state is held after the reverse. play-once comes from the CSS Animation Triggers Level 1 Editor’s Draft, not from Chrome’s announcement, so check it in your target Chrome build before relying on it. It is the closest match to calling unobserve() after the first intersection.
Activation Range vs Active Range
In a scroll trigger, the activation range decides when the trigger fires. The optional active range, written after a slash, decides how long the trigger stays active, and Chrome requires it to enclose the activation range. According to Chrome, entry 100% exit 0% equals contain and entry 0% exit 100% equals cover, so these two lines are the same:
timeline-trigger: --reveal view() entry 100% exit 0% / entry 0% exit 100%;
timeline-trigger: --reveal view() contain / cover;
With this setting, the trigger turns on once the element is fully in view and stays on until no part of it is visible. This matters for play-backwards, because the reverse animation waits until the element is off screen.
Picking Sensible Scroll Boundaries
The right scroll-trigger range depends on the element’s size and on any fixed UI covering the viewport.
| Situation | Range | Why |
|---|---|---|
| Small card or heading | entry 100% exit 0% (contain) | Fires once the whole element is visible |
| Tall section | entry 25% exit 0% | contain may fire late for tall elements |
| Under a 4rem sticky header | view(block 4rem 0) | Stops the header from hiding the element |
/* Tall element: fire when a quarter has entered */
timeline-trigger: --reveal view() entry 25% exit 0%;
/* 4rem sticky header: start (top) inset first */
timeline-trigger: --reveal view(block 4rem 0) entry 100% exit 0%;
In view(), the start inset comes before the end inset. On a vertical block axis the start is the top, so a top header’s height goes first.
Browser Support and Fallbacks
Chrome’s Google I/O 2026 Web UI roundup lists support for scroll-triggered animations in Chrome and Edge 146, with no support in Firefox or Safari. Chrome’s December 2025 announcement said the feature would land in Chrome 145, but it shipped in Chrome 146 (stable release 10 March 2026). The Chrome 146 beta post lists trigger-scope in the same release. Treat the whole feature as progressive enhancement.
The @supports wrapper in the example handles this. Unsupported browsers skip the whole block and render the content statically. If you want the same motion in other browsers, keep your existing IntersectionObserver class toggle outside the feature query, and run it only when CSS.supports() returns false. Our piece on replacing animation libraries with native web APIs covers building that fallback without AOS-style dependencies.
The prefers-reduced-motion: no-preference query means users who ask for less motion see content immediately. To stagger several elements, point their animation-trigger at one shared trigger with different delays.
Conclusion
Scroll-triggered animations cover the fade-in-on-scroll pattern: CSS decides when an ordinary animation starts, and the browser does the observing. Start with the example above. Choose the action (replay, reverse or run once) that matches your design, adjust the range for tall elements or sticky headers, and remove the observer code from supporting browsers while leaving it in place for the rest.
FAQs
What is the difference between scroll-triggered animations and scroll-state container queries?
Scroll-state container queries apply styles while a container is in a browser-managed scroll state. Scroll-triggered animations start a keyframe animation when an element crosses a range on a view or scroll timeline. Chrome 133 shipped the stuck, snapped and scrollable states, and Chrome 144 added scrolled, which tracks the most recent scroll direction. Use a scroll-state query for a sticky header that restyles once stuck, and a trigger for reveal effects.
Are scroll-triggered animations faster than an IntersectionObserver reveal?
Chrome's release notes say that because the interaction is described in CSS, the browser can hand the work to a worker thread. An IntersectionObserver reveal runs its callback and class toggle on the main thread, so heavy script work can delay it. Chrome describes the offloading as a design benefit, not a measured result, so profile your own pages before and after the switch instead of assuming a gain.
Can scroll triggers control animations created with the Web Animations API?
Yes. The Chrome 146 release notes say the feature comes with JavaScript interfaces, so triggers can drive animations made with the Web Animations API and not only CSS animations. The interface names are defined in the CSS Animation Triggers Level 1 Editor's Draft, which can still change, so check the draft and your target Chrome build before relying on them.
Why does my element stay hidden after adding a scroll trigger?
The hidden start state applies as soon as the feature query matches, so the element stays at opacity 0 until its trigger activates. Common causes are a spelling mismatch between the timeline-trigger name and the animation-trigger name, an animated element outside the subtree set by trigger-scope, or a range the element never reaches. Check the name first, then confirm the animated element sits inside the scoping element.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k