← All blog posts

How to Make Scroll-Driven CSS Animations

Diagram on a blue-to-tan gradient headed "Two timelines. No JavaScript.", under a kicker reading SCROLL-DRIVEN CSS ANIMATIONS and the line "A scroll-driven animation has no duration. You give it a distance, and the visitor scrubs it." Two white cards sit below. The left one is badged scroll() and headed HOW FAR A SCROLLER HAS SCROLLED, and holds a code snippet: a rule for .progress_bar setting animation to grow linear both and animation-timeline to scroll(root block). Under it sit a SCROLLER row listing nearest, root and self, and an AXIS row listing block, inline, x and y. It is footed with "0% at the top of the document. 100% at the bottom." The right one is badged view() and headed WHERE ONE ELEMENT IS ON SCREEN, and holds a rule for .card setting animation to rise linear both, animation-timeline to view(), and animation-range to entry 0% entry 100%. Under it sit an AXIS row listing block, inline, x and y, and an INSET row reading view(block 20%) shrinks the screen. It is footed with "0% as it enters the screen. 100% once it is fully in." A white strip along the bottom is labelled THE TWO DECLARATIONS and reads: animation-timeline replaces the clock. animation-range picks the slice of it your keyframes are spent over.

You want a thing on the page to move as somebody scrolls, and you would rather not ship a library to do it. Good news: you no longer have to.

A scroll-driven CSS animation is an ordinary @keyframes animation with its clock swapped out for a scroll position. Two declarations do it. animation-timeline says which scroll position to read, and animation-range says which slice of it your keyframes get spent over. No listener, no library, no JavaScript at all.

This is the gentle half of the subject. It is not scrolljacking, nothing gets locked in place, and the page scrolls exactly the way the visitor expects. If you want the version where a section pins and holds somebody there, that is the next post up, and it is built out of everything below. Start here.

An animation with no duration

Here is the one idea, and everything else is detail. A normal CSS animation runs on a clock: you give it half a second and the browser plays it. A scroll-driven animation has no clock and no duration. If you set one it is ignored. What it has instead is a distance, and the visitor moves along that distance by scrolling. Forwards, backwards, fast, slow, or dragged halfway and left there. It is not a playback. It is a scrub.

That is why animation-delay and animation-duration stop being the dials you reach for, and animation-range becomes the only one you will really spend time on.

Two other habits come with it. Set the timing function to linear, because easing is applied between keyframes and a scrub that speeds up and slows down under a steady finger feels broken rather than smooth. And set animation-fill-mode: both, so the from-state holds before the range starts and the to-state holds after it ends. Remember that second one. It comes back later as the worst trap in the whole feature.

Timeline one: scroll(), for how far something has scrolled

scroll() gives you the progress of a scroll container. 0% when it is at the top, 100% when it is at the bottom, and it does not care where any particular element is. That makes it the right tool for anything page-wide: a reading progress bar, a chapter rail, a colour that shifts across a long document.

.progress_bar {
  position: fixed;
  inset: 0 0 auto;
  height: 4px;
  background: #138ECF;
  transform-origin: left;
  animation: progress-grow linear both;
  animation-timeline: scroll(root block);
}

@keyframes progress-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

That is a working reading progress bar. It takes two arguments, both optional. The first names the scroller: nearest is the closest scrolling ancestor and is the default, root is the document itself, and self is the element the declaration is on, which is how a scroller reports its own progress. The second names the axis: block, inline, x or y.

root is doing real work in that snippet. The bar is position: fixed, so it has no useful scrolling ancestor to find, and nearest would resolve to nothing. Naming the document scroller explicitly is what makes a fixed element able to read a page it is not really inside.

Timeline two: view(), for where an element is on screen

view() is the one you will use more. It measures a single element’s passage across the viewport: 0% as it starts to appear, 100% once it has gone. Every element on the page gets its own, independently, with no coordination between them.

.reveal {
  animation: reveal-rise linear both;
  animation-timeline: view();
  animation-range: entry 10% entry 90%;
}

@keyframes reveal-rise {
  from { opacity: 0; transform: translateY(2rem); }
  to   { opacity: 1; transform: none; }
}

That is the fade-and-rise reveal that half the internet loads a library for, in eight lines, running off the main thread, and it goes backwards correctly when somebody scrolls back up without you writing a single line to handle that case.

view() takes an axis, the same four values, and an inset. The inset shrinks the box the timeline is measured against, which is how you say “do not start counting until it is properly on screen”. view(block 20%) pulls both ends of the viewport in by a fifth, so the reveal begins later and finishes earlier than it otherwise would. One value applies to both ends, two values set the start and the end separately, and a negative value grows the box instead.

Diagram on a blue-to-tan gradient headed "The same four names, two different subjects.", under a kicker reading THE FOUR RANGES and the line "A view() timeline measures one element passing the screen. What entry, contain and exit mean depends on whether that element is shorter or taller than the screen." Two white cards sit below. The left one, headed SUBJECT SHORTER THAN THE SCREEN and noted "a card, roughly 40% of the viewport", shows a bar split into three blocks: a narrow ENTRY block labelled 1 card tall, a wide CONTAIN block labelled screen minus card, and a narrow EXIT block labelled 1 card tall, with a tan bar underneath spanning the whole width reading COVER, ALL THREE, END TO END. A table below lists, for each range, when it starts and ends: entry starts when its top edge reaches the bottom of the screen and ends when the whole card is inside the screen; contain starts when the whole card is inside the screen and ends when the card starts to leave; exit starts when the card starts to leave and ends when its bottom edge passes the top; cover starts when the first pixel of it appears and ends when the last pixel of it leaves. It is footed with "entry-crossing and exit-crossing are identical to entry and exit here." The right card, headed SUBJECT TALLER THAN THE SCREEN and noted "a 300svh track, the pinned kind", shows the same three blocks sized one screen, track minus screen, and one screen, with the same tan COVER bar. Its table reads: entry ends when the track covers the whole screen; contain runs from the track covering the whole screen until it stops covering it; exit runs from there until its bottom edge passes the top; cover is the first pixel to the last. It is footed with "entry-crossing and exit-crossing differ here: they measure edges crossing, not containment." A white strip along the bottom is labelled WHICH ONE TO REACH FOR and reads: contain for anything that happens while the subject owns the screen, entry for an arrival, cover when one gesture should span the whole approach and departure.
The names are fixed. What they measure depends on the size of the thing being measured.

The ranges, and the one that means two different things

A view() timeline is carved into named ranges, and picking the right one is most of the craft. There are four you will use.

Here is the part that catches people. What contain means flips depending on whether your element is shorter or taller than the screen. If it is shorter, contain is the span during which the whole element sits inside the viewport. If it is taller, it is the span during which the element completely covers the viewport, which is exactly the pinned span of a tall track. Same word, opposite geometry, and it is the reason a range that behaved impeccably on a card does something baffling on a full-height section.

There are two more you will meet less often. entry-crossing and exit-crossing measure edges crossing edges rather than containment. For anything shorter than the screen they are identical to entry and exit, which is why most people never notice them; they only diverge once the subject is taller than the viewport.

The animation-range shorthand takes a start and an end, and the two halves do not have to name the same range. entry 50% contain 100% is legal and useful: begin halfway through the arrival, finish as the element starts to go. You can also set animation-range-start and animation-range-end separately, which is handy when a shared class sets one and a variant overrides the other, and you can use lengths rather than percentages if you would rather think in pixels.

Named timelines, for when the animated thing is somewhere else

scroll() and view() are anonymous: the element reads its own passage, or its own nearest scroller. Often the thing you want to animate is not in that position. The fix is to name the timeline where it is created and refer to it by name where it is used.

.gallery {
  overflow-x: auto;
  scroll-timeline: --gallery inline;
}

.gallery_frame {
  timeline-scope: --gallery;
}

.gallery_frame .gallery_bar {
  transform-origin: left;
  animation: progress-grow linear both;
  animation-timeline: --gallery;
}

A progress bar for a horizontal gallery, sitting outside the gallery so it does not scroll away with it. scroll-timeline names the scroller’s own progress. timeline-scope on a shared ancestor makes that name visible to everything inside it, rather than only to the scroller’s own descendants. view-timeline is the same idea for a view timeline, and it is the form the pinned sections on this site use, because there the element being measured and the elements being animated are deliberately different boxes.

Parallax, since somebody always asks

.hero_photo {
  animation: photo-drift linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes photo-drift {
  from { transform: translateY(-8%); }
  to   { transform: translateY(8%); }
}

This is the one case where cover is obviously right: you want the drift running the entire time the photo is on screen, including the parts where it is only half visible. Keep the travel small. Parallax stops reading as depth and starts reading as a bug somewhere around fifteen percent.

Three traps, and the first one is genuinely dangerous

A collapsed timeline holds the from-state for ever

This is the one that ships broken pages. Your animation runs with animation-fill-mode: both, which holds the from-state before the range begins. Now put that on a browser that does not support scroll timelines, or on a visitor who has asked for reduced motion and had the whole thing switched off. The timeline cannot resolve. The animation does not helpfully fall back to its finished state. It holds the from-state permanently, and if your from-state is opacity: 0, your content is now present in the HTML, read perfectly by a screen reader, and completely invisible on screen.

So build it the other way up. The un-animated page is the real page, and the animation is added on top only where it can actually run.

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal-rise linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 90%;
    }
  }
}

Nothing outside that block hides anything. A browser without scroll timelines, and a visitor who has asked their operating system for less movement, both get an ordinary page with all of its content visible, which is the correct outcome for both of them. This is not an edge case to tidy up later: reduced motion is a real slice of real traffic, and getting it wrong does not give those people a calmer page, it gives them a blank one.

The animation shorthand resets the timeline

animation-timeline is one of the properties the animation shorthand resets. So this silently does nothing:

.reveal {
  animation-timeline: view();
  animation: reveal-rise linear both;   /* resets the line above */
}

Put the shorthand first and the timeline after it, which is how every example in this post is written. Same for animation-range. Nothing warns you, the animation simply plays on the clock the moment the page loads and is finished before anybody scrolls.

An overflow on an ancestor kills it, silently

A timeline resolves against the nearest ancestor scroll container, and setting overflow: hidden on an element makes it one. So if you wrap your animated thing in a frame with hidden overflow, which is the natural way to build a frame, that frame becomes the scroll container, it never scrolls, and your animation sits at one end for ever. I have had a signature effect on a page that had never once run. Check the ancestors before you debug the keyframes.

The related version, if you need to bound horizontal overflow on a page: use overflow-x: clip, not hidden. Clip does not create a scroll container, so timelines keep resolving and position: sticky keeps working. That one is a five minute fix and a two hour diagnosis.

Keep it cheap

Animate transform, opacity and filter, and the browser can run the whole thing on the compositor without touching layout. Animate height, top, margin or anything else that moves other elements around, and you have signed up for layout and paint work on every frame of every scroll. It will look fine on your machine and terrible on a four year old phone.

The other saving is the one you already made by getting this far: no library. The reveal above is eight lines of CSS instead of a few dozen kilobytes of JavaScript that has to load, parse and run before anything moves, and the weight you never add is the only weight you never have to optimise.

Support, and the sentence that will age

Chromium browsers have had scroll-driven animations for a while and Safari has them. Firefox is the holdout at the time of writing, which is exactly the sort of claim that goes stale, so check rather than trusting a post dated August 2026. What matters more than the current state of that list is that @supports makes it a non-problem. Build the page so the un-animated version is the complete one, add the motion inside a feature query, and a browser that cannot do it renders a page that is not missing anything.

If you want a richer fallback than nothing, that is a decision rather than a requirement. This site feature-detects and loads a small trigger-based script only where scroll timelines are unavailable and motion is still wanted, so nobody who supports the real thing downloads a single byte for it. That is the shape I would recommend if you go looking for one.

When to actually use it

The mechanism is the easy half. The harder question is whether the motion earns its place, and my answer has got stricter the longer I have built with this. The test I use is whether the animation is carrying information the still version cannot: a diagram that assembles while you read it is teaching, a photograph that drifts is giving a flat page some depth, and a heading that flies in because flying in is fun is charging a visitor attention for nothing.

A reveal on every element on the page is not a designed page, it is a default. Pick the three things worth noticing and leave the rest still. Everything I got wrong building this site was some version of that lesson.

When you are ready for the version where a section pins and holds while the animation plays, that post picks up exactly here: same two declarations, same ranges, one extra box. And if you would rather have somebody build the thing properly than spend a fortnight finding out which ancestor has an overflow on it, that is what I do. Tell me what you are trying to make move and I will tell you which timeline it wants.

The Better Website Guy

Designer/developer behind The Better Website. Hand-codes every build himself: no templates, no page builders, no plugin stack.

Let's chat

← Back to the blog