One pattern behind every recipe
Give anything that should arrive the class reveal. The CSS describes only where it starts: transparent and 2rem low. A view() timeline plays the animation as the element crosses the screen, from the moment its top appears (entry 0%) until it is 40% of the way through its trip (cover 40%), which for a card is a little below the middle of the screen.
<section class="reveal">…</section>/* Only the start. The end is the element as it is styled. */
@keyframes fade-up {
from { opacity: 0; translate: 0 2rem; }
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% cover 40%;
}
}
}The keyframes have no to, so the end is whatever the element already looks like, and the same animation works on a card, a heading or an image. both holds the start before the range begins, which is what keeps it hidden until it arrives. What every range name measures, and why the shorthand comes before animation-timeline, is in CSS scroll-driven animations explained; this post sticks to recipes.
Slide in from the left or right, and zoom
Other entrances are other keyframes on the same rule. Add a second class and swap the animation-name; the timeline and the range stay as they are. Sliding uses one set of keyframes for both sides, with the distance in a custom property:
<img class="reveal from-left" src="team.jpg" alt="…">
<blockquote class="reveal from-right">…</blockquote>
<figure class="reveal zoom">…</figure>@keyframes slide-in {
from { opacity: 0; translate: var(--from-x, -4rem) 0; }
}
@keyframes zoom-in {
from { opacity: 0; scale: 0.8; }
}
/* Same .reveal rule as above; these only swap the keyframes. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal.from-left { animation-name: slide-in; }
.reveal.from-right { animation-name: slide-in; --from-x: 4rem; }
.reveal.zoom { animation-name: zoom-in; }
}
}Anything that starts outside the page’s width needs one more line. An element waiting 4rem to the right makes the page wider than the screen, and on a phone the whole page then drags sideways:
/* Something waiting 4rem to the right widens the page, and a phone
scrolls sideways. clip, not hidden: hidden makes main a scroll
container, and view() would follow it instead of the page. */
main { overflow-x: clip; }Keep the distances small. A 2rem rise or a 4rem slide reads as arriving; a full screen width reads as a slideshow, and the reader has to wait for the text to stop moving before they can read it. The CSS scroll-driven animation generator runs these and blur, rotate and a progress bar in a box you scroll, and copies the CSS with the same guards.
Stagger a row of cards
A list that runs down the page needs no stagger: every item has its own view() timeline and crosses the screen at its own moment, so they arrive one after another by themselves. A row is different. Three cards side by side cross at the same moment and arrive as one block. To cascade them, give each card its place in the row and move its range later by that much:
<ul class="cards">
<li class="reveal">…</li>
<li class="reveal">…</li>
<li class="reveal">…</li>
<li class="reveal">…</li>
<li class="reveal">…</li>
<li class="reveal">…</li>
</ul>.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* Each card's place in its row of three: 0, 1 or 2. */
.cards > :nth-child(3n + 2) { --i: 1; }
.cards > :nth-child(3n + 3) { --i: 2; }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.cards > .reveal {
/* entry 0% is cover 0%. Card i plays 8% of the crossing later. */
animation-range:
cover calc(var(--i, 0) * 8%)
cover calc(40% + var(--i, 0) * 8%);
}
}
}The nth-child rules count within each row, so the second row starts again at 0. Its place further down the page already makes it later. This works in every browser that has scroll timelines.
The newer sibling-index() counts for you, with no rule per position. It returned the right numbers in Chromium, Firefox and WebKit in our tests, but it is younger than scroll timelines, so test for it separately. Where it is missing, --i falls back to 0 and the row arrives together, still shown:
/* In place of the two nth-child rules. sibling-index() counts from 1. */
@supports (order: sibling-index()) {
.cards > * { --i: calc(sibling-index() - 1); }
}Use it for a single row or a short list. In a grid it keeps counting into the second row, which then waits for cards that are not beside it; the nth-child version stays the portable one.
Reveal an image with clip-path
Fading a photo in looks like a slow network. Wiping it in, with clip-path opening from one edge, reads as deliberate. The frame animates the clip and the photo inside settles from 115% at the same time, on the same range:
<figure class="wipe">
<img src="harbour.jpg" alt="…">
</figure>/* The photo grows inside the frame; clip keeps it from widening the page. */
.wipe { overflow: clip; }
@keyframes wipe-in {
from { clip-path: inset(0 100% 0 0); }
to { clip-path: inset(0 0 0 0); }
}
@keyframes settle {
from { scale: 1.15; }
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.wipe,
.wipe img {
animation: wipe-in linear both;
animation-timeline: view();
animation-range: entry 0% cover 45%;
}
/* The photo settles from 115% while the frame opens. */
.wipe img { animation-name: settle; }
}
}clip-path needs both ends written: from an inset() to nothing at all would jump rather than move. Change the side by changing which inset starts at 100%: inset(0 0 0 100%) opens from the right, inset(100% 0 0 0) from the bottom. Clipping does not change the layout, so the text around the image never shifts while it opens.
Progress-linked or play once
Everything above is progress-linked. A scroll timeline is a position, not an event: stop halfway and the card stays half revealed; scroll back up and it fades back out, as the middle track in Fig. 1 shows. That suits a page meant to be scrubbed. It is not what AOS taught everyone to expect, where a card animates once and then stays put.
A scroll timeline cannot express “play once, stay shown”. Nothing in it remembers that an element was ever further along. Scroll-triggered animations, a separate CSS feature that starts a timed animation when an element crosses a line, are meant for exactly this, but in our tests only Chromium understood them. Until the other engines ship them, the smallest version is an observer that adds a class:
document.documentElement.classList.add('reveal-on');
const io = new IntersectionObserver((entries) => entries.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('is-in'); io.unobserve(e.target); }
}), { rootMargin: '0px 0px -10% 0px' });
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));/* Instead of the scroll-timeline rules, not as well as them. */
@media (prefers-reduced-motion: no-preference) {
.reveal-on .reveal {
transition: opacity 0.6s ease-out, translate 0.6s ease-out;
transition-delay: calc(var(--i, 0) * 80ms);
}
.reveal-on .reveal:not(.is-in) {
opacity: 0;
translate: 0 2rem;
}
}The script marks the page before it hides anything, so if it never loads, nothing is hidden. Each element gets is-in when its top passes a line 10% above the bottom of the screen, plays a 0.6-second transition, and is never watched again. The --i from the stagger section becomes a delay here, so the row of cards still cascades. Use these rules instead of the scroll-timeline ones, not alongside them: both set opacity and translate on the same element, and they fight.
svg-scroll-draw’s scrollReveal is the in-between. It follows the scroll on the way in, like view(), and with once, on by default, keeps the furthest point reached, so scrolling back never hides anything again. It runs with or without scroll timelines, finishes at once under reduced motion, and staggers each row of a grid by reading the layout. It adds 6.1 KB:
import { scrollReveal } from 'svg-scroll-draw/reveal';
// Follows the scroll on the way in; scroll back and it stays shown.
scrollReveal('.reveal', { preset: 'fadeUp', stagger: 0.15 });The scroll reveal generator builds that call with presets, and the AOS animation examples show every data-aos effect, if that is the look you are matching.
Browser support, and the end of the page
| Browser | view() since | Notes |
|---|---|---|
| Chrome, Edge | 115 | Since July 2023: view(), scroll(), animation-range and named timelines. Opera, built on the same Chromium, has them too. |
| Safari (macOS, iOS) | 26 | Shipped with Safari 26. Earlier Safari has none of it. |
| Firefox | 159 | On by default since October 2026. Before that it sat behind a preference, off for most readers. |
Older versions skip the @supports block and show every element as written: no movement, nothing missing. That is the whole fallback. If older browsers should still see a reveal, use the observer version for everyone; it is the one that works everywhere.
One case catches every scroll-timeline reveal: the last things on a page. A card near the bottom may never travel 40% of the way up the screen, because the page ends first, and it sits there half faded for good. End those ranges when the element is fully on screen, which the page can always reach:
/* Near the bottom of the page: done once all of it is on screen. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.reveal.near-end { animation-range: entry 0% entry 100%; }
}
}The same happens to an element in the lower half of the screen when the page loads: it shows part-way through its reveal until the reader scrolls. In our tests WebKit also drew it fully shown for its first frames, before its timeline caught up, so it visibly faded backwards. Leave the reveal off anything above the fold.
— / next
Build one and copy the CSS
Pick an effect and a range, scroll the preview, and copy the CSS with its @supports and reduced-motion guards, or the same reveal as one scrollReveal call.
Star on GitHubFound this useful? A star helps others find it.