Every section on this site fades in as you scroll to it, using five lines of CSS without JavaScript, an Intersection Observer, or a scroll event listener.
.section-reveal {
animation: section-reveal linear both;
animation-duration: auto;
animation-timeline: view();
animation-range: cover 0% cover 25%;
}
CSS scroll-driven animations shipped in Chrome 115 and now work in all major browsers. They replace a pattern that required JavaScript for over a decade.
The old way
The JavaScript version requires an Intersection Observer, a callback that toggles a class, a CSS transition triggered by the class, and teardown logic:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
observer.unobserve(entry.target); // fire once
}
});
},
{ threshold: 0.15 },
);
document.querySelectorAll(".reveal").forEach((el) => observer.observe(el));
Plus the CSS:
.reveal {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.6s ease,
transform 0.6s ease;
}
.reveal.visible {
opacity: 1;
transform: translateY(0);
}
This version fires once and treats visibility as binary (hidden → visible), so scrolling back up does not reverse it. The perceptual result of a 0.15 threshold also depends on the element’s height. JavaScript must load and run before the reveals work, which can make elements flash from hidden to visible after hydration on a slow connection.
The CSS way
@keyframes section-reveal {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section-reveal {
animation: section-reveal linear both;
animation-duration: auto;
animation-timeline: view();
animation-range: cover 0% cover 25%;
}
Four properties do all the work:
animation-timeline: view() binds the animation to the element’s visibility in the viewport instead of wall-clock time. As the element scrolls into view, the animation progresses from 0% to 100%. The browser drives it.
animation-duration: auto tells the browser to stretch the keyframes across the timeline range. Without it, the animation shorthand resets duration to 0s, which collapses the keyframes to the end of a view timeline. The element then pops in instead of fading. auto fills the timeline.
animation-range: cover 0% cover 25% constrains when in the scroll the animation runs. cover 0% is the moment the element first starts intersecting the viewport. cover 25% finishes the reveal early enough to stay out of the way, but now that the animation sits on the child blocks instead of the whole section wrappers, it still reads clearly across the site.
animation: ... linear both: the linear timing function is required. Because the scroll position is the timeline, the browser already maps progress linearly to scroll distance. If you used ease-out here, you’d be easing an already-continuous scroll, which feels laggy. both means the from state applies before entry (so the element is hidden before it scrolls in) and the to state persists after the animation completes.
Why linear is not what you think
“Linear” usually means “robotic.” In time-based animation, linear is almost never what you want because it lacks acceleration. But scroll-driven animation is different. The user’s scroll gesture provides the easing. A finger flick has natural acceleration and deceleration. A trackpad scroll has momentum decay. The animation inherits the physics of the input device. Adding a CSS easing curve on top would double-ease the motion.
For scroll-driven animations, linear means “match the scroll exactly.”
Progressive enhancement for free
In a browser without animation-timeline support, the browser also ignores animation-range, so the keyframes do not run and the element stays at its natural opacity: 1; transform: none state. Unsupported browsers show the sections without animation, with no feature detection, @supports block, or JavaScript fallback.
/* Wrapped in a motion preference check for good measure */
@media (prefers-reduced-motion: no-preference) {
.section-reveal {
animation: section-reveal linear both;
animation-duration: auto;
animation-timeline: view();
animation-range: cover 0% cover 25%;
}
}
The prefers-reduced-motion guard ensures users who’ve asked for less motion don’t get the scroll-linked animation either. Two layers of progressive enhancement: capability (browser support) and preference (user choice).
Choosing the range
My first pass used entry 0% entry 25%, which completed the animation in the first quarter of the element’s entry. It looked good in the demo below, where every tile is the same 300px tall.
On the real site, the more important detail turned out not to be mobile versus desktop. It was what the timeline was attached to. If the reveal class sits on the whole section wrapper, a big grid like Featured Projects gets a much longer timeline than a small text block.
Putting .section-reveal on similarly sized child blocks, such as project cards, writing rows, and section headers for dense lists, gives the fades comparable timelines. A simple cover 0% cover 25% rule then works across the site.
You can see how the ranges compare in the demo:
What view() actually does
view() creates a ViewTimeline scoped to the element’s intersection with its nearest scrolling ancestor (usually the viewport). The timeline has four named ranges:
entry: from the element’s leading edge entering to it being fully insidecontain: while the element is fully contained within the viewportexit: from the element starting to leave to fully gonecover: the full span from first entry to last exit
Each range goes from 0% to 100%. cover 0% cover 25% means “start at the beginning of the full cover span, end at 25% through it.” You can mix ranges: entry 50% exit 50% would animate from half-entered to half-exited.
Beyond reveals
The same mechanism works for parallax, progress bars, sticky header transitions, and any property you can keyframe. It binds a CSS animation to scroll position without JavaScript.
For section reveals, that gives you scroll-linked opacity and transform with no JavaScript and a safe fallback.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.