Parallax effects have a long history, and while there are countless ways and libraries to achieve them, a new CSS-native way was recently made possible with the CSS scroll-driven animation timeline.
The general recipe was a scroll event listener in JavaScript, which recalculated the position on each frame and pushed an element up and down.
Scroll-driven animation Handle all that with CSS. There are some advantages to handling parallax animation with CSS: performance should be better since it runs it off the main thread, but my favorite part is the simplicity with which the whole thing becomes a small block of declarative styles that can be implemented with a single utility class. Here is the complete code for the class:
.parallax {
view-timeline-name: --parallax-tl;
view-timeline-axis: block;
overflow: hidden;
& > * {
scale: calc(1 + var(--parallax-offset, 20) * 2 / 100);
animation: parallax auto linear both;
animation-timeline: --parallax-tl;
animation-range: cover;
will-change: translate;
}
}
@keyframes parallax {
from {
translate: 0 calc(var(--parallax-offset, 20) * -1%);
}
to {
translate: 0 calc(var(--parallax-offset, 20) * 1%);
}
}
timeline
the trick is view-timeline-name. it makes a View progress timelinea timeline on which progress is measured .parallax The element has traveled through the scrollport. As the element starts to enter the viewport it reads 0% and once it completely leaves it reads 100%. view-timeline-axis: block Tells it to track movement along the block axis, which is vertical in normal write mode.
on the child, animation-timeline: --parallax-tl Swaps the animation’s clock from time to time on that timeline. the rest from there animation The line falls into place:
autoFor duration, because duration now comes from the timeline instead of secondslinearSo scroll progress maps straight to speed,bothTo keep the start and end frames out of the active range
⚠️ NOTE: The animation-timeline longhand property is not part of animation shorthand and must be declared separately. Additionally, animation-timeline must be declared after the animation shorthand because the shorthand will reset non-involved longhands to their initial value.
Keyframes do the actual work. With default offset, child is shifted translate: 0 -20% To translate: 0 20% As you scroll further down it. Because it moves at a different speed to the container around it, you get a sense of depth.
Scaling to avoid empty spaces
The child translates in each direction by an offset percentage of its height, so if the child is exactly the same size as its container, moving it up or down will reveal a strip of empty space.
The child needs to grow up to have margin to move forward. This requires additional height above and below the offset value, so double the offset overall:
scale: calc(1 + var(--parallax-offset, 20) * 2 / 100);
With a default offset of 20, the child is rendered at 140% of its size, with excess cropped off. overflow: hidden on the container, and there’s always enough material to cover the box, no matter where it’s sitting within ±20% of travel.
The nice thing is that both translation and scale read the same --parallax-offset Variable. Turn on the offset for a stronger effect and the scale grows to match it, so the cover stays true to itself. One value to tune, and the intervals never come back:
<div class="parallax" style="--parallax-offset: 30;">
<img src="…" />
div>
will-change: translate The last part is, a hint that this element is translate is going to change so the browser can propagate it to its own layer ahead of time.
Motion Preferences
Parallax is movement tied to scrolling, and some people won’t like it. It is good practice for anyone to respect this by disabling the animation prefers-reduced-motion: reduce. In this case, we can just turn off the animation and scale:
@media (prefers-reduced-motion: reduce) {
.parallax > * {
animation: none;
scale: 1;
}
}
resources
<a href