This article covers five ways to change an element’s colour as the page scrolls.
The short answer: for new work, animation-timeline: scroll() is the lightest option — it ties the animation to scroll position with no JavaScript at all.
▼Choosing an approach
| Approach | JavaScript | Best for |
|---|---|---|
animation-timeline: scroll() |
None | Continuous change tied to scroll position |
| Intersection Observer | Required | Changing colour when an element becomes visible |
| scroll event | Required | Branching on specific conditions |
| jQuery | Required | Sites that already load jQuery |
| CSS keyframes alone | None | Time-based, not scroll-based |
Sponsored
Driving the animation from scroll position in CSS
animation-timeline: scroll() replaces time with scroll distance as the thing that drives an animation. No JavaScript is involved.
A normal animation progresses as time passes. Setting animation-timeline makes it progress as the page scrolls instead — and scrolling back rewinds it.
@keyframes change-color {
from { background-color: #3498db; }
to { background-color: #e74c3c; }
}
.scroll-color {
animation: change-color linear;
/* driven by scroll distance, not time */
animation-timeline: scroll();
}
scroll() uses the nearest scrollable ancestor, usually the page itself. The element is blue at the top of the document, red at the bottom, and interpolates continuously in between.
To run the animation only while the element is on screen, use view().
.fade-in-color {
animation: change-color linear;
/* progresses as the element crosses the viewport */
animation-timeline: view();
/* limit the range it covers */
animation-range: entry 0% cover 50%;
}
Handling browsers that do not support it
Chrome and Edge 115 and later support it, as does Safari 18. Firefox still has it behind a flag as of September 2026, so branch with @supports.
/* the state unsupported browsers will show */
.scroll-color {
background-color: #3498db;
}
@supports (animation-timeline: scroll()) {
.scroll-color {
animation: change-color linear;
animation-timeline: scroll();
}
}
Unsupported browsers show a fixed colour and nothing breaks. As long as the page still reads without the effect, this is safe to ship.
Respect reduced-motion preferences as well:
@media (prefers-reduced-motion: reduce) {
.scroll-color {
animation: none;
}
}
Why this beats a scroll event listener
animation-timeline runs on the compositor, so it does not stall when the main thread is busy.
Changing colour from a scroll event means running JavaScript on every scroll tick. On a page doing other work, the colour change stutters or lags behind the scroll.
▼Where the work happens
| animation-timeline | scroll event | |
|---|---|---|
| Runs on | The compositor | The JavaScript main thread |
| Affected by heavy scripts | No | Stutters |
| Amount of code | A few lines of CSS | Listener plus calculation |
| Conditional logic | Limited | Unrestricted |
Use CSS for “change continuously with scroll” and JavaScript for “do something different under specific conditions”. Changing a colour falls in the first category.
Changing colour when an element becomes visible
Intersection Observer fires a callback when an element enters or leaves the viewport, which is the right tool when the change is a state switch rather than a continuous ramp.
const target = document.querySelector('.section');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle('is-visible', entry.isIntersecting);
});
}, { threshold: 0.5 });
observer.observe(target);
.section {
background-color: #3498db;
transition: background-color 0.4s;
}
.section.is-visible {
background-color: #e74c3c;
}
threshold: 0.5 means the callback fires when half the element is visible. Unlike a scroll listener, the browser does the visibility maths itself and only calls you when the answer changes.
Sponsored
Using a scroll event
A scroll listener is the right choice when the logic is genuinely conditional — different colours at different named breakpoints, for instance.
const el = document.querySelector('.scroll-color');
window.addEventListener('scroll', () => {
const ratio = window.scrollY / (document.body.scrollHeight - window.innerHeight);
el.style.backgroundColor = ratio > 0.5 ? '#e74c3c' : '#3498db';
}, { passive: true });
Pass { passive: true }. It tells the browser the listener will not call preventDefault(), so scrolling does not have to wait for your code to finish.
Using jQuery
On a site that already loads jQuery, the same idea is shorter to write:
$(window).on('scroll', function () {
const ratio = $(window).scrollTop() / ($(document).height() - $(window).height());
$('.scroll-color').css('background-color', ratio > 0.5 ? '#e74c3c' : '#3498db');
});
It carries the same main-thread cost as the plain JavaScript version. Do not add jQuery to a project for this alone — the CSS approach is both shorter and faster.
Which one to use
Continuous change with scroll: CSS. A state change when something becomes visible: Intersection Observer. Anything genuinely conditional: a scroll event.
The mistake worth avoiding is reaching for a scroll listener by default. It is the most flexible option and also the most expensive, and most colour effects do not need that flexibility.
For a related effect driven by pointer position rather than scroll, see building a mouse stalker that trails the cursor.