CSS scroll-triggered animations let you start an animation when the page reaches a particular scroll position, using CSS alone.
There are two types of scroll animations: scroll-driven animations, whose progress follows the amount scrolled, and scroll-triggered animations, which start playing at a particular scroll position. This article covers the second type.
Traditionally, starting animations in response to scroll position has usually required JavaScript, such as the Intersection Observer API. With CSS scroll-triggered animations, you can specify the scroll position that starts an animation using CSS alone.
The following examples cover everything from the basics to effects you can use on websites.
Note: Use Chrome or Edge 146 or later to view the demos in this article.
Basic syntax
Start with a simple example to see how it works.
In the following demo, the word “Animation” slides in from the right when it is fully inside the viewport.
Scroll down slowly to see the effect.
Scroll-triggered animations mainly use two CSS properties. Define a trigger with timeline-trigger, then connect it to an animation with animation-trigger. This example uses --target as the trigger name in both properties.
Specify the values in the following order. Some values are optional.
timeline-trigger:trigger namesourceactivation range/active range;animation-trigger:trigger nameaction when activatedaction when deactivated;
/* Keyframes that fade in while sliding from the right */
@keyframes motion {
from {
opacity: 0;
translate: 100px 0;
}
}
.target {
opacity: 1;
translate: 0 0;
/* Activate when fully inside the viewport; deactivate when completely outside */
timeline-trigger: --target view() contain / cover;
/* A standard time-based animation */
animation: motion 1s var(--ease-expo-out) both;
/* Play forward when activated and backward when deactivated */
animation-trigger: --target play-forwards play-backwards;
}
The view() function uses the element’s scroll position as it enters and leaves the viewport. The contain range covers the positions where the entire element fits inside the viewport, while cover covers the positions where any part of it is inside.
With contain / cover, the trigger becomes active when the entire element enters the viewport. It then stays active until the element moves completely outside the viewport.
The play-forwards action plays the animation forward when the trigger becomes active, and play-backwards plays it backward when the trigger becomes inactive. In this example, the text appears from the right when it enters the viewport and returns to its original position when it leaves. Scroll back to bring it into view again, and it plays forward again.
Checking the trigger’s active range
Scroll through the next example to see where the trigger becomes active and inactive.
The demo uses view(25%) to narrow the range to the center of the viewport, shown as a green band. The shape becomes a circle when it is fully inside the band and returns to a square when it moves completely outside.
Scroll to check when the shape changes.
CSS excerpt:
/* The shape */
.shape {
/* view(25%): inset the top and bottom by 25% each */
timeline-trigger: --range view(25%) contain / cover;
animation: morph .45s ease-in-out both;
animation-trigger: --range play-forwards play-backwards;
}
Example 1: Revealing a heading one character at a time
The next example creates an effect often seen on brand websites: text appearing one character at a time.
When each heading is fully inside the viewport, its characters appear from below in sequence. Unlike the basic example, this animation plays only once.
.hero {
/* Activate when the entire heading is visible */
timeline-trigger: --title view() contain / cover;
/* Limit references to this trigger to this element's subtree */
trigger-scope: --title;
.char {
/* Calculate the delay from sibling order and pass it to the inner span */
animation-delay: calc((sibling-index() - 1) * 0.04s);
.char__inner {
/* Reveal each character from below */
animation: rise 0.8s var(--ease-expo-out) both;
animation-trigger: --title play-once;
animation-delay: inherit;
}
}
}
@keyframes rise {
from {
translate: 0 100%;
}
}
Define timeline-trigger on the parent .hero element, and let the characters inside it reference the same --title trigger. The play-once action plays the animation only once. Unlike play-forwards, it does not restart the animation when the trigger becomes active again. Once the characters have appeared, they stay visible even after leaving and reentering the viewport.
This example has two headings that use .hero. Both use the trigger name --title. Without limiting its scope, the second heading would also animate when the first heading enters the viewport.
Use trigger-scope to limit references to the trigger to the subtree of each .hero. Each heading can then animate independently when it enters the viewport.
To reveal the characters in sequence, each character is masked with overflow: clip, and sibling-index() staggers their animation start times.
For details on sibling-index(), see “CSS staggered animations with sibling-index() and sibling-count()”.
You can also create a similar effect with GSAP, as explained in “Getting started with GSAP, part 3: ScrollTrigger, MotionPath, FLIP, and text animation”. GSAP works in all major browsers, while CSS scroll-triggered animations offer a way to build the effect with CSS alone, without an external library.
Example 2: Animating multiple elements with one trigger
The next example uses a single trigger to animate multiple elements.
As each section enters the viewport, its photo, heading, and description appear in sequence.
Scroll down to see the effect.
.work {
/* Activate as the section enters the viewport */
timeline-trigger: --work-trigger view() entry 60% cover;
/* Limit references to this trigger to this element's subtree */
trigger-scope: --work-trigger;
.work__visual {
.work__frame {
/* Gradually reveal the photo from the left */
animation: reveal-frame 1.4s var(--ease-expo-out) both;
/* Play once when activated; do nothing when deactivated */
animation-trigger: --work-trigger play-once;
}
}
.work__content {
.work__title {
.line {
padding-block: 0.04em 0.12em;
&:nth-child(1) {
--line-delay: 0.4s;
}
&:nth-child(2) {
--line-delay: 0.52s;
}
.line__inner {
/* Reveal the heading from below, one line at a time */
animation: rise-focus 1.25s var(--ease-expo-out) both;
animation-trigger: --work-trigger play-once;
animation-delay: var(--line-delay);
}
}
}
}
}
@keyframes reveal-frame {
from {
clip-path: inset(0 100% 0 0);
}
to {
clip-path: inset(0);
}
}
Multiple elements, including the photo and heading, reference --work-trigger, which is defined on the parent .work element. Because there are multiple sections, trigger-scope limits references to the trigger to each .work subtree. Different animation-delay values make the photo and heading appear in sequence.
The entry range treats the element’s movement from starting to enter the viewport to being fully inside it as progress from 0% to 100%. The value entry 60% identifies the point 60% of the way through that range. With entry 0%, playback would start as soon as the element begins to enter. This example starts at 60% so the photo and heading appear in sequence after they have moved further into the viewport. Adjusting the start position helps when revealing several elements with delays. No action is specified for deactivation, so the animation does not play backward.
Combining with scroll-driven animations
This example also uses scroll-driven animations for the photo’s vertical movement, often called parallax. As mentioned earlier, scroll-driven animations progress according to the amount scrolled, whereas scroll-triggered animations start playing at a particular scroll position.
.work__frame {
.work__image {
position: absolute;
inset: -36%;
width: calc(100% + 72%);
height: calc(100% + 72%);
/* Move vertically as the photo passes through the viewport */
animation: image-drift linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
}
@keyframes image-drift {
from {
translate: 0 -20%;
}
to {
translate: 0 20%;
}
}
The animation-timeline: view() declaration links animation progress to the photo’s movement through the viewport. Combining the two types of animation opens up more ways to create scroll effects.
For details, see “CSS Scroll-driven Animations without JavaScript”.
Example 3: Using another element as the trigger
In this example, the trigger and the animation are on separate elements.
When a color name on the right enters the scroll area, a ball of the corresponding color jumps out of a hole on the left. Scroll the area on the right to see the effect.
/* The color name on the right */
.story--red {
h2 {
/* Activate when fully inside the scroll area; deactivate when completely outside */
timeline-trigger: --red view() contain / cover;
}
}
/* The ball */
.ball {
/* Jump out of the hole, then fall back to the starting position */
animation: appear 0.8s both;
&.ball--red {
/* Restart from the beginning each time the trigger becomes active */
animation-trigger: --red replay;
}
}
@keyframes appear {
0% {
translate: -50% 80px;
animation-timing-function: var(--ease-expo-out);
}
50% {
translate: -50% -10px;
animation-timing-function: cubic-bezier(0.7, 0, 0.84, 0);
}
100% {
translate: -50% 80px;
}
}
Set timeline-trigger on the color name on the right (.story--red h2) and animation-trigger on the ball on the left (.ball--red). Both use the trigger name --red, so the corresponding ball’s animation plays when the entire color name enters the scroll area.
Elements can share a trigger name to coordinate their animations even when they are positioned apart.
The replay action restarts the animation each time the trigger becomes active. When the color name enters the area again, the ball jumps out again.
Playback actions for animation-trigger
The examples above use several playback actions for animation-trigger:
play-forwards: Play forward.play-backwards: Play backward.play-once: Play only once.replay: Restart from the beginning.
With animation-trigger, you can specify separate actions for when the trigger becomes active and when it becomes inactive.
Browser support
The animation-trigger property used for scroll-triggered animations is available in Chrome and Edge 146 or later (released in March 2026). As of October 2026, Safari 27.0 and Firefox 158 do not support it. Work on Safari support is underway, although a release date has not yet been announced.
Reference: Can I use…
Conclusion
CSS scroll-triggered animations let you start animations at a particular scroll position without JavaScript.
You can specify when an animation starts and how it plays, as well as coordinate multiple elements. These features can be useful on brand websites and elsewhere when you want to reveal content with animation as the page scrolls.
Browser support is currently limited, but this is a feature worth trying and following as support expands.


