Triggering Animations on Scroll
animation-timeline
is not supported in this browser. 😢
The goal: trigger animations once when elements cross a threshold, not control them based on scroll speed. These 4 squares will act as our elements to animate in.
I want an approach similar to what I would get with an interaction observer. Once it is determined to be enough on screen, it flips a class and plays the animation. I want to do this with just CSS. No JS needed.
The trigger must be a parent component since it changes a custom property that child elements use for their transition. The animation-name
defines which animation plays, while animation-fill-mode: both
keeps the animation in its final state by preserving both starting and ending keyframes. The animation-timeline: view()
bases the animation on the section’s visibility, and animation-range
controls when it plays—starting when 100px
are on screen and ending at 150px
. You can use percentages, but longer .trigger
elements take more time to activate.
.trigger {
animation-name: enter;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 100px 150px;
}
Here is the markup
<div class="trigger-wrapper">
<div class="layout">
<div class="box" style="--index: 0"></div>
<div class="box" style="--index: 1"></div>
<div class="box" style="--index: 2"></div>
<div class="box" style="--index: 3"></div>
</div>
</div>
The keyframes use a --value
custom property to track animation progress from 0
to 1
.
@keyframes enter {
from {
--value: 0;
}
to {
--value: 1;
}
}
The box transitions use --value
as a pseudo-boolean that interpolates from 0
to 1
. This value controls the Y-axis translation between 100px
and 0px
, while opacity directly uses --value
since it ranges from 0
to 1
. Each box’s transition delay is based on its --index
custom property, creating a staggered animation effect.
.box {
transform: translateY(calc(100px - var(--value) * 100px));
transition: transform 1s ease-in-out, opacity 1s ease-in-out;
transition-delay: calc(200ms * var(--index));
opacity: var(--value);
}
Here is it all put together.
HTML
<div class="trigger">
<div class="layout">
<div class="box" style="--index: 0"></div>
<div class="box" style="--index: 1"></div>
<div class="box" style="--index: 2"></div>
<div class="box" style="--index: 3"></div>
</div>
</div>
CSS
:root {
--gap: 30px;
--duration: 1s;
--delay: 200ms;
/* https://easingwizard.com/ */
--glide: linear(
0,
0.012 0.9%,
0.049 2%,
0.409 9.3%,
0.513 11.9%,
0.606 14.7%,
0.691 17.9%,
0.762 21.3%,
0.82 25%,
0.868 29.1%,
0.907 33.6%,
0.937 38.7%,
0.976 51.3%,
0.994 68.8%,
1
);
}
.trigger {
margin-block: 3rem;
animation-name: enter;
animation-fill-mode: both;
animation-timeline: view();
animation-range: entry 100px 150px;
}
.layout {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
gap: var(--gap);
}
.box {
display: inline-flex;
width: 100%;
max-width: 500px;
aspect-ratio: 1;
background: #b3c33a;
transform: translateY(calc(100px - var(--value) * 100px));
transition: transform var(--duration) var(--glide), opacity var(--duration) var(
--glide
);
transition-delay: calc(var(--delay) * var(--index));
opacity: var(--value);
}
@keyframes enter {
from {
--value: 0;
}
to {
--value: 1;
}
}
JS
// 404