Chrome has shipped scroll-triggered animations, and is the primary browser to take action. In the event you replace to Chrome 146, you possibly can view the demo under, the place the background of a sq. fades in over the length of 300ms, however solely as soon as the entire factor is inside the viewport.
It is a bit completely different to how scroll-pushed animations work, so on this article I’ll examine them, after which present you ways scroll-triggered animations work.
Scroll-triggered animations play for a hard and fast length as soon as a sure scroll threshold has been surpassed. (Assume JavaScript’s Intersection Observer API however for CSS animations.)
This differs from scroll-driven animations, the place animation development is synchronized with scroll development (animation-timeline: scroll()) or the diploma of intersection (animation-timeline: view()), and thus has no length.
The important thing half is timeline-trigger: view() as a substitute of animation-timeline: view(), which waits for the factor to be inside the threshold as a substitute of measuring how a lot it’s inside it and doing one thing accordingly. Nevertheless, let’s begin with the precise @keyframes animation, which units the background:
/* Outline the animation */
@keyframes fade-bg-in {
to {
background: currentColor;
}
}
It’s set on the .sq. over the length of 300ms:
.sq. {
/* Declare animation */
animation: fade-bg-in 300ms;
}
By default, CSS animations set off when the declaration is utilized, however within the expanded snippet under, timeline-trigger overwrites that habits. Now the animation triggers when the factor comes into view(). The --trigger is just a dashed ident that acts as an identifier for the set off, whereas entry 100% exit 0% is a timeline vary. A timeline vary specifies the scroll zone wherein the animation prompts and is allowed to stay energetic.
On this case, the animation triggers when the underside fringe of the .sq. enters the (entry 100%) and untriggers (assuming that it’s nonetheless operating) when the highest edge exits the scrollport (exit 0%). For readability, entry 0% would set off the animation when the prime edge enters. entry handles the factor coming in from the underside of the scrollport, whereas exit handles it leaving by way of the highest. It’s a bit complicated, but it surely’s simpler to know if I don’t over-explain it.
.sq. {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation set off situations */
timeline-trigger: --trigger view() entry 100% exit 0%;
}
For animation-trigger, we first specify which set off we’re speaking about, after which we declare some settings (e.g., play-forwards):
.sq. {
/* Declare animation */
animation: fade-bg-in 300ms;
/* Animation set off situations */
timeline-trigger: --trigger view() entry 100% exit 0%;
/* Animation set off settings */
animation-trigger: --trigger play-forwards;
}
The play-forwards key phrase triggers the animation each time the sq. turns into fully seen, and since we haven’t declared a fill mode for the animation (utilizing animation-fill-mode or as a part of the animation shorthand), which implies that the sq. received’t retain the background after, the animation is extra of a flash.
So, we have to construct upon this to realize completely different outcomes.
animation-fill-mode vs.
First, a recap of what the completely different fill mode values do for animation-fill-mode or as a part of the animation shorthand:
forwards: the types are retained after the animation.backwards: the types are utilized earlier than the animation.each: each behaviors are utilized.
Now, let’s assume that the is play-forwards (like earlier than) and the fill mode is forwards (each could be redundant as a result of background isn’t even set to start with):
.sq. {
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
animation-trigger: --trigger play-forwards;
}
This causes the types to be retained, however, if the sq. partially or fully exits the viewport after which reenters it, the animation restarts, which might trigger a flash relying on how the animation ends, which is what occurs on this occasion.
There are two alternative ways to unravel this…
The “lock-in” technique: Use play-once as a substitute of play-forwards, which, when mixed with forwards, ends in the animation taking part in as soon as, by no means to restart, after which retaining the types afterward.
.sq. {
/* Play as soon as */
animation-trigger: --trigger play-once;
/* Retain the types */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
The “back-and-forth” technique: play-forwards play-backwards animates the factor usually when totally seen and in reverse when not totally seen. There’s no flash as a result of the factor animates backward as easily because it animates ahead. As well as, although the path of the animation can change, the fill mode can stay at forwards as a substitute of being set to each.
Why?
play-forwards means “play the animation from 0% to 100%” whereas play-backwards means “play the animation from 100% to 0%.” In the meantime, as I discussed earlier, the forwards fill mode means “retain the types when the animation completes’”— effectively, that is no matter whether or not the ultimate keyframe is 0% or 100%.
.sq. {
/* Play ahead and backward, as applicable */
animation-trigger: --trigger play-forwards play-backwards;
/* Retain the types both manner */
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
}
play-forwards, play-once, and play-backwards aren’t the one key phrases for . Right here’s a fast rundown:
|
Impact |
|---|---|
none |
For disabling triggers conditionally, on entry however not exit (or vice-versa), or dealing with a number of triggers with one animation-trigger |
play-forwards |
Permits the animation to play ahead |
play-backwards |
Permits the animation to play backward |
play-once |
Ahead or backward (whichever comes first) |
play |
Performs within the final specified path, or ahead if neither has been specified |
pause |
Pauses the animation |
reset |
Pauses the animation and units progress to 0 |
replay |
Units progress to 0 however doesn’t pause the animation |
These s not solely permit for a major quantity of management over animations whereas scrolling, however completely different combos of actions, fill modes, timeline ranges, and the truth that we are able to bake exit animations into @keyframes guidelines implies that there are sometimes a number of methods to realize an final result.
Whereas scroll-triggered animations being made up of animation actions, fill modes, timeline ranges, and possibly extra, may appear overcomplicated, the truth that these mechanics are decoupled allow us to reuse logic whereas sustaining flexibility, lowering repetition and making the mechanics extra design system-friendly.
Take into account three squares this time, and for a little bit of added complexity, we declare scale: 70% (animates to preliminary) and outline two rotative animations.
/* Outline animations */
@keyframes intensify {
to {
scale: preliminary;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.sq. {
/* Set beginning worth */
scale: 70%;
}
After that it’s extra of the identical, and whereas it’s clearly a extra advanced instance, having the ability to merge values into shorthand properties and decouple them into longhand properties, in addition to the decoupled nature of the completely different mechanics, facilitates flexibility but in addition reusability (on this case, to stagger numerous animations utilizing the identical animation set off settings):
.sq. {
/* Set beginning worth */
scale: 70%;
/* Outline animation title */
--base-animation: intensify;
/* Declare animation */
animation: var(--base-animation) 300ms forwards;
/* Outline animation set off settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one in every of both rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
/* Declare animation set off situations (with out timeline ranges) */
timeline-trigger: --trigger view();
/* Declare energetic vary finish */
timeline-trigger-active-range-end: regular;
/* Append different 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%;
}
}
Right here’s a cleaner, extra strong model that makes use of sibling-count() and sibling-index() (which lack Firefox assist) to stagger the animations:
On this model, as a substitute of setting timeline-trigger-activation-range-start on every particular person sq., we merely goal .sq. and calculate the entry values on the fly:
/* Most entry ÷ variety of squares */
--stagger-interval: calc(100% / sibling-count());
/* Present sq.’s index × stagger interval */
--entry: calc(sibling-index() * var(--stagger-interval));
/* Declare animation set off situations */
timeline-trigger: --trigger view() entry var(--entry) exit 0%;
Making one factor set off different components
On this case, we’ll shift the set off and its ranges to the primary sq., and have the opposite squares observe in keeping with a staggered animation delay. As you possibly can see, all animations are triggered by animation-trigger as soon as 50% of the primary sq. has entered (entry 50%) the viewport (view()). animation-trigger is triggered by timeline-trigger as a result of the dashed ident (the aptly named --trigger) hyperlinks them:
/* Outline animations */
@keyframes intensify {
to {
scale: preliminary;
background: currentColor;
}
}
@keyframes rotate-left {
to {
rotate: -5deg;
}
}
@keyframes rotate-right {
to {
rotate: 5deg;
}
}
.sq. {
/* Set beginning worth */
scale: 70%;
/* Outline animation title */
--base-animation: intensify;
/* Most delay ÷ variety of squares */
--stagger-interval: calc(300ms / sibling-count());
/* Present sq.’s index × stagger interval */
--animation-delay: calc(sibling-index() * var(--stagger-interval));
/* Declare animation */
animation: var(--base-animation) 300ms var(--animation-delay) forwards;
/* Outline animation set off settings */
--animation-trigger: --trigger play-forwards play-backwards;
/* Declare for intensify, then for one in every of both rotate animations */
animation-trigger: var(--animation-trigger), var(--animation-trigger);
&:first-child {
/* Declare animation set off situations */
timeline-trigger: --trigger view() entry 50%;
/* Declare energetic vary finish */
timeline-trigger-active-range-end: regular;
}
/* Append different animations */
&.rotate-left {
animation-name: var(--base-animation), rotate-left;
}
&.rotate-right {
animation-name: var(--base-animation), rotate-right;
}
}
One draw back is that when animation-trigger is in play-backwards mode, the animations don’t stagger. It's because, I feel, when the animation is reversed, the delay is included in that. This looks as if an oversight to me, particularly as that isn’t the case with animation-direction: reverse, however I may very well be fully improper on this.
Understanding timeline ranges
Timeline ranges are a giant a part of scroll-triggered animations, however they’re a separate mechanic. For scroll-pushed animations, you’ll need animation-range and its longhand properties. With scroll-triggered animations, the syntax is basically the identical however makes use of completely different properties and two completely different ranges. The activation vary determines the scroll zone wherein the animation triggers, whereas the energetic vary determines the zone wherein it holds up (even when not within the activation vary anymore).
Timeline ranges are a bit heavy. Nevertheless, view() entry 100% exit 0% (when totally seen) and view() comprise (the identical but in addition if bigger than the viewport) will suffice more often than not.
However when you’re eager to dive in, animation-range, though it’s for scroll-pushed animations, is lighter and affords a novice-level understanding of timeline ranges. After that, I like to recommend studying the Animation Triggers spec to cowl the numerous intricacies of timeline ranges inside the context of those scroll-triggered animations.
One other ingredient of scroll-triggered animations that’s additionally its personal factor is the view() perform, however this one’s simpler to summarize right here. Mainly, on the subject of scroll-triggered animations, view() is the viewport. So when you had a 5rem sticky header, view(y 0 5rem) would make the timeline vary issue that in alongside the y-axis.
Closing ideas
Scroll-triggered animations might be difficult as a result of they’re just like scroll-driven animations, they leverage older CSS options (primarily animation) in addition to mechanics from different newer options (dashed idents, view(), timeline ranges), along with the CSS properties which might be particular to scroll-triggered animations. There’s an entire lot occurring without delay.
I’m undecided how I really feel about them, to be sincere. They’re positively cool, enjoyable, and helpful, however they’re additionally difficult, and it’ll be some time earlier than I actually begin to rave about them.





![How creators and entrepreneurs are utilizing AI to hurry up & succeed [data]](https://blog.aimactgrow.com/wp-content/uploads/2025/06/Untitled20design-Apr-07-2023-08-24-35-4586-PM-120x86.png)


