Using CSS scroll-triggered animations
CSS scroll-triggered animations provide a declarative mechanism to start, pause, stop, or reverse an element's DocumentTimeline-based CSS animation when the user scrolls it (or a different element) to a specified offset within a scrollport.
This article covers how to create CSS scroll-triggered animations.
Scroll-triggered animation concepts
A common UI pattern involves triggering animations on a web page when the user scrolls to a certain place in the content, for example, to pull in additional UI elements or draw the user's attention to certain details.
CSS scroll-triggered animations enable defining scroll-based triggers that start and stop regular time-based CSS animations. You can define trigger positions inside a scroll container so that, when a tracked element reaches those positions within the scrollport, they toggle the play state of an animation applied to that element, or to a completely different element.
Note: Scroll-triggered animations provide an alternative to using JavaScript features — such as frameworks or the Intersection Observer API — to trigger animations on scroll. CSS scroll-triggered animations are more performant and, arguably, simpler to implement.
Scroll-triggered versus scroll-driven animations
Scroll-triggered animations are similar to CSS scroll-driven animations, but they are different:
- Scroll-triggered animations are regular time-based animations that play when a trigger becomes active; they observe the
animation-delayeach time the animation starts, and always complete each iteration in the amount of time defined by theanimation-duration, regardless of how fast the user scrolls. - With scroll-driven animations, the normal, time-based animation timeline is replaced by a scroll-based timeline, meaning that the animation progresses forwards and backwards as you scroll towards the start and end of the content, respectively, with faster scrolling resulting in a faster animation. Scroll-driven animations ignore the
animation-durationandanimation-delayproperties.
Scroll-triggered animation basics
Let's walk through a basic example to show you how a scroll-triggered animation works. An image caption will fade in and out when the image it is captioning is scrolled into and out of view. In this case:
- The
<figcaption>element has a@keyframesanimation set on it: a fade-in effect. This animation is the triggered animation. - For the animation actions, we specify that the animation should play forwards when the trigger is activated, fading the caption in, and play backwards when the trigger is deactivated, fading the caption out.
- The animation triggers are defined on the
<img>element. The activation is triggered when the<img>starts entering the scrollport and the deactivation occurs when the<img>fully exits the viewport, meaning the full viewport is the timeline range, the<img>is the tracked element, and the<figcaption>is the animated element. - We'll set an anonymous view progress timeline as the trigger source on the
<img>element, created using theview()function.
As the content is scrolled up and down, the caption's animation will start as soon as the <img> begins to appear inside the scrollport, with the animation reversing when the <img> exits the scrollport. This basic example doesn't create the effect we want, but it's a good start that we can improve on as we learn about more features.
The HTML features several paragraphs of content with a <figure> element included in the middle of them, which contains the <img> and <figcaption>. For the sake of brevity, we are not showing the full source.
...
<p>...</p>
<figure>
<img
src="jungle-coast.jpg"
alt="A view across some trees towards a rocky coast" />
<figcaption>A view of the Jungle coast</figcaption>
</figure>
<p>...</p>
...
We'll start by defining @keyframes for the fade-in animation we'll apply to our <figcaption>.
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
The first declaration block applies the animation and an animation trigger, along with the animation actions, to the <figcaption> element:
- We use the
animationshorthand to apply thefade-inanimation to<figcaption>. By itself, without triggers, this would cause the<figcaption>to fade into view as soon as the page loads. - We use the
animation-triggerproperty to delay the start of the animation until the<img>element is scrolled into view by identifying which element will provide the triggers, and what those trigger's actions will be. Theanimation-triggerproperty value components include:- A
<dashed-ident>,--t, which is the identifier set as the value of thetimeline-trigger-nameproperty on the triggering element. - Two
<animation-action>values, which specify how the animation should behave when the trigger is activated and deactivated (the animation actions). When the trigger is activated, we set the<figcaption>element's animation to play forwards. When the trigger is deactivated, we set the<figcaption>element's animation to play backwards.
- A
figcaption {
animation: fade-in 1s ease-in both;
animation-trigger: --t play-forwards play-backwards;
}
The second declaration block creates the animation trigger:
- We use the
timeline-trigger-nameproperty to give the<img>element an identifying name for triggers created on it. This is the same dashed identifier as the trigger name referenced in the<figcaption>element'sanimation-triggerproperty value. - We use the
timeline-trigger-sourceproperty to create the animation trigger type. Specifying theview()function means our trigger type is an anonymous view progress timeline, which has a default activation range equal to thecovertimeline range.
img {
timeline-trigger-name: --t;
timeline-trigger-source: view();
}
By default, the ViewTimeline created by the view() function tracks the position of the <img> element across the block-axis of the nearest parent scroller. The element that is tracked, in this case the <img>, is the subject or the tracked element.
The triggers are activated and deactivated when the tracked element is scrolled to the start and end of the timeline range respectively, in the block direction, causing the <figcaption> animation to play forwards and play backwards. This is the activation range. The default cover activation range is from the point when the tracked element's start border edge starts to enter the scrollport to the point when the tracked element's end border edge completely exits the scrollport.
The example renders like so:
Note how the caption starts to fade in as soon as any part of the image becomes visible in the scrollport, whether you are moving it in from the bottom or the top. It doesn't fade out again until the entire image has moved out of the scrollport, so you won't be able to see the fading out effect; if you scroll the image back into view, the caption will fade in again.
Creating the trigger on the same element
In the previous example, the trigger was defined on the <img> element, and the <figcaption> was animated. It is possible to define the trigger on the animated element itself. Let's modify the previous example to create the trigger on the animated <figcaption> element.
The HTML is identical to the previous example. The CSS differs only in where the timeline-trigger-* properties are set.
This time, the animation, animation-trigger, timeline-trigger-name, and timeline-trigger-source properties are all set on the <figcaption> element — it will animate when it appears in the scrollport. In the previous example, the <figcaption> was the animated element and the <img> was the tracked element. Now the caption plays both roles.
figcaption {
animation: fade-in 1s ease-in both;
animation-trigger: --t play-forwards play-backwards;
timeline-trigger-name: --t;
timeline-trigger-source: view();
}
The updated rendering looks like this:
In this case, the <figcaption> fades into view when it, rather than the image, first enters into the scrollport.
Adjusting the trigger activation range
In the previous examples, the trigger activates (fade-in starts) as soon as a block edge of the tracked element enters the scrollport at one edge, and deactivates (fade-out starts: fade-in is played backwards) when the tracked element has finished exiting the scrollport at the opposite edge. As a result, the fade out is never visible. This is because the default activation range <timeline-range-name> when using view() as the timeline-trigger-source is cover.
To make the fade out animation visible, we can offset the start and end of the activation range using the timeline-trigger-activation-range-start and timeline-trigger-activation-range-end properties, respectively, or the timeline-trigger-activation-range shorthand to set both values in a single declaration. Each of these properties can take as values:
- The
normaldefault value. - A
<length-percentage>value to specify a point along the default range. - A
<timeline-range-name>keyword specifying a named range. - A
timeline-range-nameand a<length-percentage>to specify a point along the named range.
Percentages are relative to the length of the <timeline-range-name>, which resolves to cover for our view progress timeline. Had we set scroll() as our timeline-trigger-source, the default <timeline-range-name> would have resolved to scroll. See Timeline range names to learn about the <timeline-range-name> values.
The following example will cause the trigger to activate 50% of the way through the entry range (when 50% of the tracked element has entered the scrollport via one of the scrollport's block edges) and deactivate 0% of the way through the exit range (when 50% of the tracked element has exited the scrollport's opposite block edge).
timeline-trigger-activation-range: entry 50% exit 0%;
Let's apply this to our first example so you can see what the effect is. Our img declaration block is updated to the following:
img {
timeline-trigger-name: --t;
timeline-trigger-source: view();
timeline-trigger-activation-range: entry 50% exit 0%;
}
The updated rendering looks like this:
The animation of the <figcaption> is now a bit more useful — it only starts to fade into view when a significant portion of the <img> has entered the scrollport at its end edge, and it starts to fade out when the <img> has started to exit the scrollport at its start edge. When you scroll the content back down again, the trigger reactivates and the fade-in occurs again at the scrollport's start edge, and deactivation occurs again at the scrollport's end edge.
Setting a custom active range
The active range is the range within which a trigger will remain activated once activation has occurred. By default, the active range is the same as the activation range; therefore, deactivation will occur once the tracked element leaves the activation range. This is what we've seen in our examples so far.
It is possible to set an active range that is different to the activation range using the timeline-trigger-active-range-start and timeline-trigger-active-range-end properties, or the timeline-trigger-active-range shorthand to set both values in a single declaration.
You might want to do this to extend the time an animation has to complete — for example, if you have an animation trigger that activates only within a small range, but once activated, you want it to stay active over a larger range. Only when the tracked element moves out of the active range does the trigger become inactive; after that, you can activate it again by moving the subject back into the activation range.
Let's build on our previous examples to demonstrate the effect of the active range. The HTML is the same, except we've included two identical <figure> elements with classes of .one and .two, placed next to one another using flexbox, meaning they'll come in and go out of view at the same time as each other. In each case, the <img> will be the tracked element for its sibling animated <figcaption>.
<div class="figure-wrapper">
<figure class="one">
<img
src="https://mdn.github.io/shared-assets/images/examples/learn/gallery/pic5.jpg"
alt="A butterfly with red, white, and gold wing sections, sitting in a leaf" />
<figcaption>
A beautiful butterfly seen in the Jungle near Cairns
</figcaption>
</figure>
<figure class="two">
<img
src="https://mdn.github.io/shared-assets/images/examples/learn/gallery/pic5.jpg"
alt="A butterfly with red, white, and gold wing sections, sitting in a leaf" />
<figcaption>
A beautiful butterfly seen in the Jungle near Cairns
</figcaption>
</figure>
</div>
We apply the same animation to both <figcaption> elements as in previous examples, but their animation-trigger property values reference two different timeline-trigger-name values.
figcaption {
animation: fade-in 0.4s ease-in both;
}
.one figcaption {
animation-trigger: --t1 play-forwards play-backwards;
}
.two figcaption {
animation-trigger: --t2 play-forwards play-backwards;
}
We set the same timeline-trigger-source and the same timeline-trigger-activation-range on both <img> elements. The timeline-trigger-name for each <img> element reference the two different dashed identifiers in the previous code block, which means the trigger created on each <img> acts as the trigger for the animation on its own sibling <figcaption>.
The timeline-trigger-activation-range: contain 40% contain 60% declaration means that the trigger activates — and thus the animation starts playing — when the tracked element reaches a narrow range, the middle 20% of the scrollport, and deactivates when the subject exits that narrow range.
By default, the activation range is the same as the active range. However, we also set a timeline-trigger-active-range range of entry 50% exit 100% on the second <img>. This means that, once faded in, the second <figcaption> will only fade out again when the second <img> scrolls to exit 100%, which is when it has completely left the scrollport.
img {
timeline-trigger-source: view();
timeline-trigger-activation-range: contain 40% contain 60%;
}
.one img {
timeline-trigger-name: --t1;
}
.two img {
timeline-trigger-name: --t2;
timeline-trigger-active-range: entry 50% exit 100%;
}
Warning:
Because of the way the active range works, the timeline-trigger-active-range should always be set to a larger range than the timeline-trigger-activation-range. If set to a smaller range, it will have no effect.
This example renders like so:
Scroll the images into view, and then scroll them carefully up and down. Note how both captions fade into view at the same time, at a point roughly one third up the embedded page. The first caption fades out again slightly further up, whereas the second caption doesn't fade out until it has been moved completely out of the scrollport. This is because both <img> triggers have the same activation range, but only the second one has a much larger active range applied to it.
The timeline-trigger shorthand
So far, we've written all the CSS for our scroll-triggered animation as a mixture of shorthand and longhand properties in order to best explain each of the properties and their values. However, this is cumbersome and wordy. Now that you've understood the concepts, we can use the timeline-trigger shorthand to create the shortest possible equivalent. You'll likely opt for this shorthand in your future projects.
Taking these declarations as an example:
img {
timeline-trigger-name: --t;
timeline-trigger-source: view();
timeline-trigger-activation-range: contain 25% contain 75%;
timeline-trigger-active-range: entry 0% exit 100%;
}
We can rewrite these in a single line of CSS using the timeline-trigger shorthand:
img {
timeline-trigger: --t view() contain 25% contain 75% / entry 0% exit 100%;
}
Adjusting the animation's action
All the examples so far in this guide have triggered the fade-in animation, causing the caption to fade in and fade out. The forwards and backwards play is controlled by the animation-trigger declaration set on the animated element:
animation-trigger: --t play-forwards play-backwards;
The <animation-action> values play-forwards and play-backwards specify that the animation plays forwards when the trigger activates, and backwards when the trigger deactivates. The first value is the animation activation action, and the second value is the animation deactivation action.
If we set the following animation declaration on the same element:
animation: fade-in 1s ease-in both;
The animation only happens once when the trigger is activated, and once in reverse when it is deactivated: we didn't specify an animation-iteration-count in our animation shorthand, therefore the default value, 1, is used.
There are other animation-action values that can be set to produce different effects. For example:
play-oncecauses the animation to play only once. Once finished, it won't play again on subsequent activations/deactivations.playcauses the animation to play, in whatever direction it was previously playing in. In contrast,play-forwardsandplay-backwardsinteract with the animation'splaybackRate, setting it to its absolute positive value or its absolute positive value multiplied by-1, respectively. This results in the animation playing forwards or backwards. Note that theanimation-directionvalue is not affected.pausecauses the animation to pause. For example, you could set an animation's iteration count toinfinite, and set the correspondinganimation-triggerto--t play pause: This would cause the animation to play when the trigger is activated, and pause when it deactivates.resethas the same effect aspause, except that additionally it sets the animation progress back to0.
Some of these values are designed to be used together. For example, play-forwards play-backwards is intended for use in cases where you want to alternate the direction of play in the animation's end visual effect, causing a UI element to "animate in" when it appears on-screen and then "animate out" again when it goes off-screen. On the other hand, play pause is common for animating an element as it appears, then pausing the animation as it starts to go off-screen.
Let's look at a brief example — we'll take our first example and change it so that our <figure> fades in only once, when has completely entered the scrollport, and doesn't fade out or animate again until the page is reloaded.
We set the animation-trigger property's animation-action to play-once so that the animation only plays once when the <figure> first enters the activation range. We've also set the timeline-trigger-activation-range to contain so that the animation only plays when the <figure> is completely on-screen — it only plays once, so we don't want you to miss it.
figure {
animation: fade-in 1s ease-in both;
animation-trigger: --t play-once;
timeline-trigger: --t view() contain;
}
This example renders like so:
When you first scroll the <figure> on-screen, it will fade in. After that, it will stay at 100% opacity regardless of how many times you scroll it up and down the scrollport. You can only get it to fade in again by refreshing the page (or reloading the embedded example's <iframe>).
Trigger scope
If multiple triggers use the same timeline-trigger-name, because of how triggers are determined by the browser by default, the triggers will be associated with the last element in the HTML source order that has that timeline-trigger-name value. This is likely not the desired behavior.
For example, if a document contains multiple repeated components, each containing a scroll-triggered animation where the animated element and tracked element are different elements, all of the animated elements will have their animations controlled by the last component's trigger, unless you use a different timeline-trigger-name in each component, or scope the name by subtree.
The trigger-scope property limits the visibility, or "scope", of a timeline-trigger-name value to a specific subtree. The result is that each animated element can only have its animation triggered by a trigger created within the same scoped subtree. See the trigger-scope reference page for details of how this works and a trigger-scope example.
Multiple scroll-triggered animations
In previous examples, we set only a single scroll-triggered animation on an element; however, all of the animation-* and timeline-trigger-* properties discussed in this guide accept a comma separated list of values to enable triggering multiple animations from multiple triggers. In this section we'll build a slightly more complex example with multiple scroll-triggered animations on the same element.
The animation-trigger property works in exactly the same way as the animation shorthand property and the other animation longhand properties with regards to setting multiple values. If multiple animation-name values are set, but only a single animation-trigger value is set, the animation-trigger will apply to all the animations. If two animation-trigger values are set, they will cycle between the animations until all of them have an animation-trigger value set. And so on.
This example progressively animates an element — applying further animations when new triggers are activated as the page scrolls. As the user scrolls, the element first slides in from the right of the screen, then reveals its contents, then slides down the screen and changes its background color.
The HTML is similar to previous examples except that we have included a <section> element at the top containing some highlighted content, and some empty <div> elements interspersed throughout our main content that will have triggers defined on them to trigger our animations.
<section>
<h2>This content is animated!</h2>
<p>
The countryside surrounding Cairns, located in eastern Australia, is a
breathtakingly beautiful region characterized by diverse landscapes, lush
greenery, and unique natural wonders.
</p>
</section>
<h1>Information about Cairns</h1>
...
Initially, the highlighted content <section> is hidden off-screen. Our CSS starts by styling the <section> element, giving it a position of fixed and positioning it near the top-left of the scrollport. We also define the initial styles that we will be animating from and returning to. We then set three animation values, which will make the <section> element slide-from-right, then reveal its contents, then slide-down the screen and change its background color. We also set an animation-trigger for each one of the animations so that they will be triggered by different triggers being activated.
We want the finished state of each animation to apply throughout, after it is reached, therefore it is important to set appropriate animation-fill-mode values on the animations and <animation-action> values on the animation-trigger values to achieve this. We had to set an animation-fill-mode of forwards rather than both on the last animation because there is no from keyframe.
section {
position: fixed;
left: 1em;
top: 1em;
height: 240px;
background: red;
width: 400px;
transform-origin: top;
animation:
slide-from-right 1s both,
reveal 1s both,
slide-down 1s forwards;
animation-trigger:
--t1 play-forwards pause,
--t2 play-forwards pause,
--t3 play-forwards pause;
}
Next, we create triggers on the <div> elements, setting their timeline-trigger-name equal to the identifiers specified in the <section> elements' animation-trigger property values. This means that as the user scrolls, when each tracked <div> enters the scrollport, a different animation is activated. In this case, our tracked elements are invisible — they don't contain any useful content and are only here to create the triggers.
#one {
timeline-trigger: --t1 view();
}
#two {
timeline-trigger: --t2 view();
}
#three {
timeline-trigger: --t3 view();
}
Finally, we define the animation @keyframes referenced in the <section> element's animation property earlier on.
@keyframes slide-from-right {
from {
translate: 400%;
}
to {
translate: 0;
}
}
@keyframes reveal {
from {
color: #fff0;
transform: scaleY(0.2);
}
to {
color: #ffff;
transform: scaleY(1);
}
}
@keyframes slide-down {
to {
translate: 0 100%;
background: blue;
}
}
This example renders as follows:
Try carefully scrolling the example, and note how each animation is applied to the <section> when each <div> is scrolled to.
Multiple triggers for the same animation
If you have an animated element, and you want to define triggers on multiple different elements that all trigger the same animation, you need to specify the same named animation multiple times on the animated element, and then give each instance of that animation a different trigger. See Triggering the same animation via multiple different triggers for more information.
See also
- CSS animation triggers module
- CSS animations module
- CSS scroll-driven animations module
- Using the Web Animations API
- CSS scroll-triggered animations are coming! on developer.chrome.com (2025)