Parallax Is Back, This Time in Pure CSS
During the 2010s, parallax scrolling was the go-to trick for making a site feel dynamic. The effect — where page elements move at different speeds as the user scrolls — creates an illusion of depth that most users find visually compelling. But the classic implementation relied on JavaScript, which brought with it the risk of main-thread blocking and janky performance.
Scroll-driven CSS animations change that. With native support for timeline-based animations, it is now possible to rebuild parallax effects using only CSS, keeping the animation work off the main thread entirely. Browser support is still in progress — Chrome, Edge, and Opera have it enabled, and Firefox offers it behind a flag — but the feature is ready for experimentation.
Setting Up a Scroll-Driven Animation
Imagine a universe-themed page with alternating hero and text sections. The hero sections each contain a background pattern, a heading, and some icon elements. The goal is to create a parallax effect where the background and the heading move at different rates as the page scrolls.
Start by defining a @keyframes rule that animates the background position. Attaching it to each hero section via the animation property runs the animation automatically on page load — but that is duration-based, not scroll-based. To tie the animation's progress to the scroll position, the animation-timeline property must be set to a timeline function.
Scroll-driven animations introduce two new timeline functions: view() and scroll(). Using scroll() couples the animation to the scroll position within a container. The hero section's background will then move as the user scrolls, reversing when scrolling back up:
section.hero {
/* previous code */
- animation: parallax 3s linear;
+ animation: parallax linear;
+ animation-timeline: scroll();
}
Switching to the View Timeline
The scroll() timeline has a significant drawback: elements near the bottom of the page will have nearly completed their animation before they enter the viewport. The better option for per-element effects is the view() timeline, which calculates progress based on where the subject sits relative to the scrollport.
Applying view() to the hero text animation produces a smoother result, but not a perfect one. When scrolling back up, the heading content appears to flash back into view. That happens because the timeline is computed from the element's pre-animation position, which may already be outside the viewport when the animation would begin.
The fix is an inset parameter passed to view(). A negative inset effectively enlarges the timeline area, starting the animation before the element is actually visible and ending it after it has passed out of the viewport:
- animation-timeline: view();
+ animation-timeline: view(-100px);
Fine-Tuning With Animation Ranges
An alternative to the inset parameter is the animation-range property, which adjusts the start and end points of the animation along the timeline. This approach keeps the view() timeline untouched while still allowing precise control over when the animation plays.
For example, animating the #spaceship emoji with a view() timeline will again cause it to jump back to its original position once it scrolls out of the viewport. Extending the animation range to continue beyond the timeline's normal end point solves this without changing the inset:
#spaceship {
animation: launch;
animation-timeline: view();
+ animation-range: 0% 120%;
}
The same technique can delay the start of an animation. Setting the range to begin only after the element passes a threshold distance from the bottom of the scrollport keeps the animation inactive until that point:
@keyframes rotate {
from {
transform: rotate(0deg) translateX(100px);
}
to {
transform: rotate(-70deg) translateX(0px);
}
}
#comet {
animation: rotate linear;
transform-origin: center 125px;
animation-timeline: view();
animation-range: 4rem 120%;
}
Animation ranges can also chain multiple animations within a single timeline. A first animation might run until the subject reaches a certain percentage of the scrollport, after which a second animation takes over for the remainder of the timeline:
@keyframes orbit-in {
0% {
transform: rotate(200deg);
}
100% {
transform: rotate(0deg);
}
}
@keyframes orbit-out {
0% {
transform: translate(0px, 0px);
}
100% {
transform: translate(-50px, -15px);
}
}
#satellite {
animation: orbit-in linear, orbit-out ease;
animation-timeline: view();
animation-range: 0% 80%, 80% 110%;
}
Reduced Motion and Fallbacks
Because this page has many moving parts, it's worth accounting for users who prefer reduced motion. The prefers-reduced-motion media feature handles this. The simplest approach in this case is to disable all scroll-driven animations when the setting is set to reduce, since the content remains accessible and complete without them:
@media (prefers-reduced-motion: reduce) {
.my-selector {
animation: none !important;
}
}
Browser support for scroll-driven animations remains uneven, so it is worth planning a fallback. A polyfill exists that reproduces the effect in unsupported browsers, but it forces the animation back onto the main thread, which partially defeats the performance benefit. If main-thread performance is a priority, skipping the animation in unsupported browsers via an @supports rule is likely the cleaner trade-off.
A Modern Reintroduction
Parallax may feel retro, but the scroll-driven approach makes it a much more practical feature than before. Using scroll() and view() timelines — combined with ranges and careful handling of user preferences — recreates the classic layered-scrolling effect without any JavaScript overhead. It's an old idea, but the new mechanism is a clear improvement.



