02 / blog / how-toNovember 20269 min read

Animate on scroll with CSS only (no library, no JavaScript)

Fading things in as they scroll into view used to need a library. Today it is one @keyframes rule on a view() timeline. Here are the recipes people reach for: fade up, slide in from either side, zoom, a staggered row of cards and an image wiped in with clip-path. Each one stays visible where it cannot run, and each one respects reduced motion. One job CSS cannot do yet, play once and stay shown, takes 5 lines of JavaScript.

  • How-To
  • animation-timeline
  • Scroll reveal
  • CSS only
Recipes
fade · slide · zoom · stagger · wipe
Unsupported browsers
shown as written
Play once
5 lines of JS
The readerscroll position
view()follows the scroll
Observer + classplays once, stays
Fig. 1 — one card, scrolled in, then out again. On a view() timeline the reveal is a position: it follows the scroll and plays backwards when the reader scrolls back. An observer that adds a class plays it once, on the clock, and leaves it shown.
01The pattern

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.

index.html1 line
<section class="reveal">…</section>
reveal.css14 lines
/* 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.

02Recipes

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:

index.html3 lines
<img class="reveal from-left" src="team.jpg" alt="…">
<blockquote class="reveal from-right">…</blockquote>
<figure class="reveal zoom">…</figure>
entrances.css16 lines
@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:

layout.css4 lines
/* 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.

03Lists

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:

index.html8 lines
<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>
stagger.css19 lines
.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:

row.css4 lines
/* 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.

04clip-path

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:

index.html3 lines
<figure class="wipe">
  <img src="harbour.jpg" alt="…">
</figure>
wipe.css24 lines
/* 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.

05The limit

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:

once.js5 lines
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));
once.css11 lines
/* 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:

reveal.js4 lines
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.

06Browsers

Browser support, and the end of the page

Browserview() sinceNotes
Chrome, Edge115Since July 2023: view(), scroll(), animation-range and named timelines. Opera, built on the same Chromium, has them too.
Safari (macOS, iOS)26Shipped with Safari 26. Earlier Safari has none of it.
Firefox159On 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:

reveal.css6 lines
/* 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.

→Parts list

Keep reading

More from the blog