Chrome 146 introduces scroll-triggered animations
With Chrome 146, scroll-triggered animations are now available, making Chrome the first browser to support them. These animations play for a fixed duration once a scroll threshold is crossed. The feature can be thought of as a CSS-native equivalent to JavaScript’s Intersection Observer API, but instead of firing callbacks, it starts CSS animations.
This is distinct from scroll-driven animations, which tie animation progress directly to scroll progress via animation-timeline: scroll() or animation-timeline: view(). Those animations have no intrinsic duration; scroll-triggered ones do, playing for a set time like a normal CSS animation.
The core difference comes down to one declaration. For scroll-triggered animations, you use timeline-trigger: view() instead of animation-timeline: view(). This tells the browser to wait until the element enters the viewport rather than continuously mapping scroll position to animation state. By default, a CSS animation starts as soon as it is declared. Adding timeline-trigger overrides that behavior.
A minimal scroll-triggered setup
Imagine a square whose background fades in over 300ms once the entire element is visible. The @keyframes rule handling the background looks like this:
/* Define the animation */
@keyframes fade-bg-in {
to {
background: currentColor;
}
}
The animation is applied to .square with a 300ms duration, but triggering it requires specifying a timeline range. The dashed ident --trigger acts as the identifier linking the trigger to the animation. The range entry 100% exit 0% defines the scroll zone where the trigger is active. In this instance, the animation activates once the bottom edge of the square enters at entry 100% and deactivates when the top edge exits at exit 0%. For reference, entry 0% would fire when the top edge enters. The entry and exit keywords map to the element moving into and out of the scrollport, respectively.
.square {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation trigger conditions */
timeline-trigger: --trigger view() entry 100% exit 0%;
}
The animation-trigger property references that dashed ident and sets trigger-specific behavior with keyword values like play-forwards:
.square {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation trigger conditions */
timeline-trigger: --trigger view() entry 100% exit 0%;
/* Animation trigger settings */
animation-trigger: --trigger play-forwards;
}
With a play-forwards action, the animation fires once the square is fully visible. Without a declared animation-fill-mode, however, the styled background is discarded as soon as the animation completes, making the effect read as a brief flash.
Behaviors, fill modes, and combinations
The <animation-action> keywords give the animation its directional behavior on scroll:
<animation-action> | Effect |
|---|---|
none | For disabling triggers conditionally, on entry but not exit (or vice-versa), or handling multiple triggers with one animation-trigger |
play-forwards | Allows the animation to play forward |
play-backwards | Allows the animation to play backward |
play-once | Forward or backward (whichever comes first) |
play | Plays in the last specified direction, or forward if neither has been specified |
pause | Pauses the animation |
reset | Pauses the animation and sets progress to 0 |
replay | Sets progress to 0 but doesn’t pause the animation |
Recapping fill modes:
forwards: retains styles after the animation ends.backwards: applies styles before the animation begins.both: applies both behaviors.
With play-forwards as the action and forwards as the fill mode, the background stays applied. Doing so introduces another issue: if the square leaves the viewport and returns, the animation plays again from the start and causes an unwanted flashing effect. One remedy is switching to play-once. Combined with the forwards fill mode, the animation runs a single time and never restarts, retaining the styles produced at completion.
.square {
/* Play once */
animation-trigger: --trigger play-once;
/* Retain the styles */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
An alternative is the play-forwards play-backwards pairing. This plays the animation normally as the element becomes fully visible and reverses it when it leaves that state. The square animates back as smoothly as it came in, eliminating any pop. Notably, the fill mode can stay forwards rather than switching to both. This works because play-forwards runs the animation from 0% to 100% while play-backwards runs it from 100% back to 0%. The forwards fill mode retains styles at the end of the animation regardless of whether the animation stops at a 0% or 100% keyframe.
.square {
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
animation-trigger: --trigger play-forwards;
}
.square {
/* Play forward and backward, as appropriate */
animation-trigger: --trigger play-forwards play-backwards;
/* Retain the styles either way */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
Triggering animation groups with reusable rules
Scroll-triggered animations are built from decoupled mechanics: animation-actions, fill modes, and timeline ranges. That separation allows the same logic to be applied to multiple elements. The flexibility to inject exit animations directly into @keyframes also means several ways exist to arrive at the same visual result.
Staggering several elements can be done by manually assigning timeline-trigger-activation-range-start values to each item, accommodating multiple @keyframes rules and animated properties like scale:
<div id="squares">
<div class="square rotate-left"></div>
<div class="square"></div>
<div class="square rotate-right"></div>
</div>
/* Define animations */
@keyframes intensify {
to {
scale: initial;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.square {
/* Set starting value */
scale: 70%;
}
.square {
/* Set starting value */
scale: 70%;
/* Define animation name */
--base-animation: intensify;
/* Declare animation */
animation: var(--base-animation) 300ms forwards;
/* Define animation trigger settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one of either rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
/* Declare animation trigger conditions (without timeline ranges) */
timeline-trigger: --trigger view();
/* Declare active range end */
timeline-trigger-active-range-end: normal;
/* Append other animations */
&.rotate-left {
animation-name: var(--base-animation), rotate-left;
}
&.rotate-right {
animation-name: var(--base-animation), rotate-right;
}
/* Stagger activation ranges */
&:first-child {
timeline-trigger-activation-range-start: entry 33.3333%;
}
&:nth-child(2) {
timeline-trigger-activation-range-start: entry 66.6666%;
}
&:last-child {
timeline-trigger-activation-range-start: entry 99.9999%;
}
}
Grouping the trigger settings becomes cleaner with sibling-count() and sibling-index(), though these two functions do not yet have Firefox support. A single declaration targets all squares and calculates entry offsets dynamically, removing the need to hand-set an activation range start per element:
/* Maximum entry ÷ number of squares */
--stagger-interval: calc(100% / sibling-count());
/* Current square’s index × stagger interval */
--entry: calc(sibling-index() * var(--stagger-interval));
/* Declare animation trigger conditions */
timeline-trigger: --trigger view() entry var(--entry) exit 0%;
Letting one element drive another
The trigger can be attached to one element while the animations themselves are declared on other elements. Below, animation-trigger fires when 50% of the first square has entered the viewport (entry 50% plus view()). Every other animation then runs with a staggered animation-delay. Again, the dashed ident is what connects the elements: timeline-trigger on the first square exposes --trigger, which animation-trigger on the other squares references.
/* Define animations */
@keyframes intensify {
to {
scale: initial;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.square {
/* Set starting value */
scale: 70%;
/* Define animation name */
--base-animation: intensify;
/* Maximum delay ÷ number of squares */
--stagger-interval: calc(300ms / sibling-count());
/* Current square’s index × stagger interval */
--animation-delay: calc(sibling-index() * var(--stagger-interval));
/* Declare animation */
animation: var(--base-animation) 300ms var(--animation-delay) forwards;
/* Define animation trigger settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one of either rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
&:first-child {
/* Declare animation trigger conditions */
timeline-trigger: --trigger view() entry 50%;
/* Declare active range end */
timeline-trigger-active-range-end: normal;
}
/* Append other animations */
&.rotate-left {
animation-name: var(--base-animation), rotate-left;
}
&.rotate-right {
animation-name: var(--base-animation), rotate-right;
}
}
Staggering breaks when play-backwards reverses those animation delays, an effect that does not occur with animation-direction: reverse. This looks like a design gap, where reversing the animation incorrectly includes the delay.
Timeline ranges and the view() function
Timeline ranges are necessary for this feature, but they are technically a partner mechanism. In scroll-triggered context, the activation range is the scroll window in which the animation can fire, while the active range defines where the animation stays valid even outside the activation range. For most use cases, the shorthand view() entry 100% exit 0% (fire when fully in view) or view() contain (same behavior, plus handling for elements larger than the viewport) is sufficient. To go deeper, the scroll-driven property animation-range is a friendlier starting point, followed by the Animation Triggers specification.
The view() function itself represents the viewport in scroll-triggered animation syntax. Its inset arguments let you adjust for fixed UI: for example, a 5rem sticky header can be subtracted with view(y 0 5rem) so the geometry becomes active only below the header.



