Scroll inside this box ↓
No view() support here, so items simply show.
view() inside a scroll container, entry 0% cover 30%; scroll back up and it reverses.Scroll inside this box ↓
No view() support here, so items simply show.
cover range: fades in, holds, then fades out at the top.Scroll inside this box ↓
Two ways to reveal on scroll
A fade-in on scroll lets a section arrive as the reader reaches it instead of sitting there from the start. For years that meant a scroll listener measuring every element. There are now two better options. CSS scroll-driven animations tie a normal @keyframes animation to the element’s position in its scroll container, with no script at all. IntersectionObserver is the JavaScript route, and it lets the browser tell you when an element becomes visible rather than making you ask on every scroll event.
This recipe uses the CSS version where supported, keeps the observer as a fallback, and makes sure that if neither runs, the reader still sees every word.
The CSS-only reveal with animation-timeline: view()
Normally an animation’s progress is driven by time. The animation-timeline property swaps that clock for a scroll position. MDN’s animation-timeline reference describes view() as an anonymous view progress timeline on the nearest ancestor scroll container. The animation sits at 0% as the element enters the scrollport and reaches 100% as it leaves.
That “nearest ancestor scroll container” is why the first two demos work inside small boxes. You do not need page scroll; any element with overflow-y: auto and content taller than itself will do. On a real page the nearest scroller is usually the viewport, and the same CSS works unchanged.
@keyframes fs-rise {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: none; }
}
@supports (animation-timeline: view()) {
.fs-rise {
animation: fs-rise linear both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
Three details in that block are easy to get wrong. First, order: the animation shorthand resets animation-timeline to auto, so the timeline must come after it. The Chrome team’s scroll-driven animations guide calls this out explicitly. Second, there is no duration. A time in seconds means nothing on a scroll timeline, so leave it out and let it default to auto. Third, linear easing and a fill mode of both: linear keeps the effect locked to the scrollbar, and both holds the first keyframe before the range starts and the last one after it ends.
One trap catches almost everyone eventually. A wrapper with overflow-x: hidden becomes a scroll container in both directions. view() then tracks that wrapper instead of the viewport, and because the wrapper never scrolls, nothing animates. Use overflow-x: clip, which clips without creating a scroll container.
Reading animation-range: entry 0% cover 30%
Without a range the animation would play across the whole journey through the scrollport, finishing only as the element disappeared off the top, which is no use for a reveal. animation-range picks a slice of that journey. MDN’s animation-range page lists the named ranges, and four matter here:
cover: the full journey, from the element’s first pixel entering to its last pixel leaving.entry: from first pixel in to fully inside (or filling the scrollport, if it is taller than it).exit: the mirror ofentryat the top edge.contain: the stretch where the element is fully inside the scrollport, or fully covers it.
So entry 0% cover 30% starts the fade the moment the element’s top edge appears and finishes it 30% of the way through the full journey, which in a typical viewport is roughly a third of the way up the screen. Starting later than 0% leaves an empty gap at the bottom of the screen; finishing at entry 100% makes tall elements snap in and short ones fade almost too fast to notice. Ranges are measured on the element’s untransformed box, so the translateY() in the keyframes does not shift its own trigger points.
The second demo uses the full range and puts the timing into the keyframes instead, so each item fades in, holds while it is in the middle band, and fades out near the top:
@keyframes fs-spotlight {
0% { opacity: 0; transform: translateY(16px) scale(0.96); }
30%, 70% { opacity: 1; transform: none; }
100% { opacity: 0; transform: translateY(-16px) scale(0.96); }
}
@supports (animation-timeline: view()) {
.fs-spotlight {
animation: fs-spotlight linear both;
animation-timeline: view();
animation-range: cover 0% cover 100%;
}
}
Use the fade-out sparingly: it suits a short sequence like a process diagram, not body text.
Browser support in October 2026
Scroll-driven animations shipped in Chrome and Edge 115 in July 2023 and in Safari 26, on macOS and iOS, in September 2025. Firefox is the holdout. MDN’s list of Firefox experimental features shows them enabled by default only in Nightly, from version 136; in Beta and Release they sit behind the layout.css.scroll-driven-animations.enabled preference, which is off. MDN therefore marks animation-timeline as “Limited availability”, not Baseline.
That is why every scroll-driven rule in this recipe sits inside @supports (animation-timeline: view()). The @keyframes can live outside it, because keyframes do nothing until an animation rule uses them. A browser that fails the test never applies the animation, so it never applies the 0% keyframe either: the content is simply there, fully opaque, in its final position. That is the correct result. Never set opacity: 0 outside the @supports block and rely on the animation to undo it.
Never hide content that only JavaScript can reveal
The single most damaging version of this effect is also the most copied:
/* Don’t: invisible to everyone until a script runs */
.reveal { opacity: 0; }
.reveal.is-visible { opacity: 1; }
Here the stylesheet hides the content and only a script can bring it back. If that script fails to download, is blocked by an extension, throws an error earlier in the bundle, or simply has not executed yet when a crawler takes its snapshot, the page is blank below the fold. Readers and search engines alike see empty space.
The rule is that the default, script-free state must be the finished state. The common fix is a class on the root element, set by a one-line script in the <head>, with the hiding rules scoped to html.js .reveal. Better, but it still breaks if the observer script later fails. The demo goes one step further: the observer script itself adds the hiding class, fs-wait, and only to items the observer has just reported as out of view. Hiding and revealing cannot be separated, and anything already on screen is never hidden, so nothing flickers.
The IntersectionObserver fallback
IntersectionObserver has worked in every major browser since 2019; MDN’s IntersectionObserver reference lists it as Baseline widely available. You hand the browser a list of targets and it calls you back when their visibility crosses a threshold. The callback still runs on the main thread, but only when something changes.
.fs-io.fs-wait {
opacity: 0;
transform: translateY(24px);
}
.fs-io:not(.fs-wait) {
transition: opacity 0.6s ease, transform 0.6s ease;
}
const scroller = document.getElementById('fs-io-scroller');
if (scroller && 'IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.remove('fs-wait');
io.unobserve(entry.target);
} else {
entry.target.classList.add('fs-wait');
}
}
}, { root: scroller, threshold: 0.2 });
scroller.querySelectorAll('.fs-io').forEach((el) => io.observe(el));
}
The observer’s first callback reports every target, which is where off-screen items get fs-wait; items already visible are simply left alone. After that, threshold: 0.2 fires once a fifth of an element is visible, and removing the class lets the transition play. The root option exists only because the demo lives in a box; on a page, omit it. Calling unobserve() makes it play once and stops the browser tracking the element. To use this purely as a fallback, return early when CSS.supports('animation-timeline', 'view()') is true, and observe your page’s reveal elements with no root.
Unlike the CSS version, this reveal is a timed transition. It plays at its own speed once triggered, and it does not reverse when you scroll back up.
| Approach | JS needed | Runs on main thread? | Support (Oct 2026) | Best for |
|---|---|---|---|---|
animation-timeline: view() | None | No, for opacity and transform; the compositor tracks scroll | Chrome and Edge 115+, Safari 26+; Firefox only in Nightly or behind a flag | Reveals tied to scroll position that reverse naturally |
| IntersectionObserver + class | About 14 lines | Callback yes, but only on visibility changes; the transition itself can be composited | All current browsers (Baseline since 2019) | One-shot reveals, and the fallback for Firefox |
Scroll listener + getBoundingClientRect() | Yes | Yes, on every scroll event, forcing layout | All browsers | Nothing new; replace it with one of the above |
| No animation | None | Nothing to run | All browsers | Above-the-fold content, body text, reduced motion |
Animate opacity and transform, nothing else
All three demos move only opacity and transform, and that is deliberate. Those two properties can be handled by the compositor, so the browser does not repaint or reflow the element each frame. The Chrome guide notes that scroll-driven animations of such properties run off the main thread, which means a heavy script cannot make them stutter. Animate margin-top, height or filter: blur() instead and you are back to layout or paint work on every frame of scrolling. Our guide to CSS animation performance explains the rendering pipeline behind this.
Keep it below the fold, and off most paragraphs
Never fade in the content people see first. web.dev’s Largest Contentful Paint article notes that Chromium does not count elements with an opacity of 0 as LCP candidates, so a hero heading or image that starts invisible pushes your LCP back until it starts to appear.
Layout shift is less of a worry as long as you stick to transform: moving an element with translateY() does not count towards Cumulative Layout Shift, whereas revealing content by changing height, margin or display does. Our Core Web Vitals guide covers how both metrics are measured.
Then be selective. Reveal section headings, cards or images, the things that mark a new chunk of content, not every paragraph. When each line of text fades in, scrolling at reading speed means the words you want are always half-transparent, and the novelty wears off by the second screen.
Reduced motion: fade, don’t travel
The prefers-reduced-motion media feature reports the user’s system setting to minimise motion. For a reveal, the travel is the part that can cause discomfort; a change in opacity is not motion. So under reduced motion, swap the keyframes for opacity-only versions, or remove the animation entirely if you prefer:
@keyframes fs-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: reduce) {
.fs-rise { animation-name: fs-fade; }
.fs-spotlight { animation-name: fs-fade-spotlight; }
}
}
@media (prefers-reduced-motion: reduce) {
.fs-io.fs-wait { transform: none; }
}
Turn on reduced motion in your operating system and scroll the demos again: everything still fades, nothing slides. If you want a ready-made, time-based version for content that should animate on load rather than on scroll, the fade-in-up card in our library is the same keyframes with a duration, and the CSS entrance animations category has the rest of the family.