This article covers how to build a scroll-driven parallax effect.
The short answer: for new work, animation-timeline: scroll() gives you the smoothest result with no JavaScript at all.
If jQuery is already on the page, or the logic genuinely needs branching, the jQuery version below still applies. But writing styles directly inside a scroll handler will stutter, and the fix for that is covered too.
Sponsored
The parallax effect we are building
See the Pen
Parallax by rin (@rinblog0408)
on CodePen.
Loading jQuery
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
Sponsored
Marking up the elements
Give each element that should move at its own rate a class of its own.
<div class="parallax-container">
<div class="parallax-item1">Content 1</div>
<div class="parallax-item2">Content 2</div>
<div class="parallax-item3">Content 3</div>
</div>
Applying parallax with jQuery
$(window).on('scroll', function() {
const scrollPosition = $(this).scrollTop();
$('.parallax-item1').css('transform', 'translateY(' + scrollPosition * 0.4 + 'px)');
$('.parallax-item2').css('transform', 'translateY(' + scrollPosition * 0.7 + 'px)');
$('.parallax-item3').css('transform', 'translateY(' + scrollPosition * 0.1 + 'px)');
});
Why this stutters
A scroll event fires dozens of times per scroll gesture. Rewriting styles on each one outpaces what the browser can paint.
The fix is to record the position during the scroll and apply it on the next animation frame.
let scrollPosition = 0;
let ticking = false;
function update() {
$('.parallax-item1').css('transform', 'translateY(' + scrollPosition * 0.4 + 'px)');
$('.parallax-item2').css('transform', 'translateY(' + scrollPosition * 0.7 + 'px)');
$('.parallax-item3').css('transform', 'translateY(' + scrollPosition * 0.1 + 'px)');
ticking = false;
}
$(window).on('scroll', function() {
scrollPosition = $(this).scrollTop();
if (!ticking) {
requestAnimationFrame(update);
ticking = true;
}
});
The ticking flag limits the work to once per frame. Without it, you queue one requestAnimationFrame per scroll event and gain nothing.
In plain JavaScript, mark the listener passive. That tells the browser scrolling need not wait for your handler.
window.addEventListener('scroll', onScroll, { passive: true });
Sponsored
Parallax in CSS alone
animation-timeline: scroll() ties an animation’s progress to scroll position, which removes the need for any of the above.
@keyframes parallax-slow {
from { transform: translateY(0); }
to { transform: translateY(200px); }
}
.parallax-item1 {
animation: parallax-slow linear;
/* driven by scroll distance, not time */
animation-timeline: scroll();
}
Vary the distance per element and you get the sense of depth. Only the keyframe value changes.
This runs on the compositor, so it does not stutter when the main thread is busy — the fundamental advantage over a scroll handler.
Support is Chrome and Edge 115 onwards and Safari 18; Firefox still has it behind a flag as of September 2026. Branch with @supports:
@supports (animation-timeline: scroll()) {
.parallax-item1 {
animation: parallax-slow linear;
animation-timeline: scroll();
}
}
Always honour reduced-motion preferences here. Parallax is one of the effects most likely to trigger motion sickness, so this is not an optional nicety.
@media (prefers-reduced-motion: reduce) {
.parallax-item1,
.parallax-item2,
.parallax-item3 {
animation: none;
transform: none;
}
}
Summary
Use CSS animation-timeline for new work; use jQuery plus requestAnimationFrame when adding to an existing site.
▼Where the work happens
animation-timeline |
scroll event | |
|---|---|---|
| Runs on | The compositor | The main thread |
| Affected by heavy scripts | No | Stutters |
| Amount of code | A few lines of CSS | Listener plus rAF |
| Conditional logic | Limited | Unrestricted |
The same property drives the examples in 5 ways to change colour on scroll and adding scroll-driven animation to images.