← All blog posts

How to Make Scroll-Driven CSS Animations

Diagram on a blue-to-tan gradient headed "Eight lines, and no JavaScript.", under a kicker reading SCROLL-DRIVEN CSS ANIMATIONS and the line "The scroll position is the timeline. The card fades up as it arrives, and it runs backwards if the visitor scrolls back." Two white panels sit below. The left one, labelled THE WHOLE THING, holds a code snippet: a rule for .reveal setting animation to rise linear both, animation-timeline to view(), and animation-range to entry 10% entry 90%, followed by a keyframes block named rise running from opacity 0 and translate 0 2rem to opacity 1 and translate 0 0. The right one, labelled WHAT THAT LOOKS LIKE, holds three small screen diagrams captioned arriving, halfway and settled: a blue block labelled CARD sits almost transparent at the bottom edge of the screen, then higher and half visible, then fully opaque in the middle. A white strip along the bottom is labelled THE TWO NEW LINES and reads: animation-timeline swaps the clock for a scroll position. animation-range picks which slice of it the keyframes are spent over.

You want something on the page to move as a visitor 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 the keyframes get spent over. No listener, no library, no JavaScript at all.

This post starts with the simplest useful example and gets more ambitious a section at a time: one element fading in, then the same element leaving again, then the whole document. Nothing here pins a section or takes the scroll away from anybody. If you want that version, it is the next post up and it is built out of everything below.

Start with a reveal

Here is the whole thing. A card that fades in and rises into place as it arrives on screen, which is the effect most people are actually asking for when they ask about this.

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

@keyframes rise {
  from { opacity: 0; translate: 0 2rem; }
  to   { opacity: 1; translate: 0 0; }
}

Paste that, put class="reveal" on something, and it works. It is worth being precise about which parts are new, because only two of those lines are.

The @keyframes block is exactly what you have always written. So is animation: rise linear both, give or take the values. The two new lines are animation-timeline: view(), which tells the animation to read this element’s own passage across the screen instead of a clock, and animation-range, which says to spend the keyframes between a tenth and nine tenths of the way through its arrival.

Two of the old values are doing more work than usual, though. linear matters because easing is applied between keyframes, and a scrub that speeds up and slows down under a steady finger feels broken rather than smooth. both is the fill mode, and it holds the from-state before the range starts and the to-state after it ends, which is what stops the card flickering to full opacity before its turn. Remember that one. It comes back later as the worst trap in the whole feature.

What is actually happening

One idea underneath all of this, and the rest 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, and that is why it goes into reverse correctly on the way back up without you writing anything to handle it. It is also 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.

The measurements: where entry, contain and exit actually are

So the range is the dial, which makes it worth knowing exactly what it is measuring. A view() timeline tracks one element crossing the screen, and that crossing has four moments in it. Everything you will ever write is a percentage of the distance between two of them.

Diagram on a blue-to-tan gradient headed "Where entry, contain and exit actually are.", under a kicker reading THE MEASUREMENTS and the line "One card, one screen, four moments. The ranges are the distances between them, and every animation-range you write is a percentage of one of these." A legend shows a white box outlined in black meaning the screen and a plain grey box meaning off screen, still in the document. A white panel below holds four small diagrams in a row, each a grey tile with a white screen rectangle inside it and a blue block labelled CARD. In the first the card sits just below the screen, captioned cover 0%, top edge touches the bottom. In the second the card is inside the screen at its lower edge, captioned entry 100%, contain 0%, fully on screen. In the third the card is inside the screen at its upper edge, captioned contain 100%, exit 0%, starting to leave. In the fourth the card sits just above the screen, captioned exit 100%, cover 100%, bottom edge clears the top. Between the diagrams sit three labelled bars: ENTRY, noted one card tall; CONTAIN, noted screen minus card; and EXIT, noted one card tall. A tan bar spans the full width beneath them reading COVER, all three end to end, the card's entire life on screen. A white strip along the bottom is labelled READING A RANGE and reads: entry 10% entry 90% starts a tenth into the arrival and finishes a tenth before it ends. Both halves can name different ranges: entry 50% contain 100% is legal, and useful.
Four moments, three distances between them, and a fourth name for all three at once.

Editing a range is four moves and nothing else. Move the first number to change when a beat starts. Move the second number to change when it finishes, and bringing it in early is how you buy a pause: an animation that ends at entry 70% leaves the rest of the arrival as a held frame. Stagger the same range across siblings, three or four points apart, to make a group cascade instead of landing as a slab. And both halves can name different ranges, which is the bit nobody explains: entry 50% contain 100% means begin halfway through the arrival and finish as the element starts to leave. There are also animation-range-start and animation-range-end if a shared class sets one and a variant overrides the other, and you can use lengths instead of percentages if you would rather think in pixels.

One more dial, which is the second argument to view() itself. An inset shrinks the box the timeline is measured against, so view(block 20%) pulls both ends of the screen in by a fifth and the reveal starts later and finishes earlier without you touching the range. One value does both ends, two values set them separately, and a negative value grows the box instead. It is the cleanest way to say “do not start counting until it is properly on screen”.

The one gotcha worth carrying with you: what contain means flips depending on whether the element is shorter or taller than the screen. Shorter, and it is the span where the whole element sits inside the viewport, as above. Taller, and it is the span where the element completely covers the viewport, which is the pinned span of a tall section. Same word, opposite geometry, and it is why a range that behaved impeccably on a card does something baffling on a full-height section. There are two further names, entry-crossing and exit-crossing, which 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 meet them.

Now let it leave again

The reveal spends its keyframes inside one range, which is the common case and also the boring one. The question that turns up immediately afterwards is what to do when you want the thing to arrive and leave. There are two answers and they are not equivalent.

The first is two animations on one element, each with its own range. All three of these properties take a comma-separated list, and the lists line up position by position.

.reveal {
  animation: rise linear backwards, fall linear forwards;
  animation-timeline: view(), view();
  animation-range: entry 10% entry 90%, exit 10% exit 90%;
}

@keyframes rise {
  from { opacity: 0; translate: 0 2rem; }
  to   { opacity: 1; translate: 0 0; }
}

@keyframes fall {
  from { opacity: 1; translate: 0 0; }
  to   { opacity: 0; translate: 0 -2rem; }
}
Diagram on a blue-to-tan gradient headed "Two animations, two ranges, one element.", under a kicker reading AN ENTRANCE AND AN EXIT and the line "The fill modes are what keep them out of each other's way. Each one contributes nothing outside its own range, so the settled middle is just the element's own CSS." A white panel labelled ONE PASS ACROSS THE SCREEN holds a single horizontal lane split into three parts. A narrow blue block on the left reads RISE, animation-range: entry, fill: backwards. A wide dashed grey block in the middle reads NEITHER IN EFFECT, the element renders as its own CSS. A narrow brown block on the right reads FALL, animation-range: exit, fill: forwards. Underneath, the lane is labelled entry 0% to 100%, contain, and exit 0% to 100%. A red-bordered note below reads: Write both on both and the entrance never runs. Later animations win for the same property wherever they are in effect, and both means the exit is filling backwards from the top of the page. Measured: opacity sits at 1.00 for the entire arrival. Nothing errors. A white strip along the bottom is labelled WHY AN EXIT IS FINE HERE and reads: A triggered outro plays to a viewport that has already moved on. A scrubbed one is tied to the scroll position, so it plays exactly as the element leaves, and reverses if the visitor does.
Each animation owns one range and stays out of the other one entirely.

The fill modes are the whole trick, and writing both on both of them is a real trap. Two animations on one element that touch the same property are resolved in order, and the later one wins wherever it is in effect. both means the exit is filling backwards from the top of the page, holding its own from-state over everything the entrance is trying to do, so the entrance never appears: the element sits there at full opacity until it leaves. Nothing errors and nothing warns you. You have written a fade-in that does not fade in.

backwards on the entrance and forwards on the exit fixes it, because each one then contributes nothing outside its own span. The entrance holds its from-state before entry and lets go afterwards, the exit stays out of the way until exit begins, and in between neither is in effect, so the element renders as its own ordinary CSS. That middle stretch is the settled state, which is what you wanted it to be anyway.

The second answer is one animation across cover, with the hold written into the keyframes as a pair of identical stops.

.reveal {
  animation: reveal-both-ways linear both;
  animation-timeline: view();
  animation-range: cover 0% cover 100%;
}

@keyframes reveal-both-ways {
  0%   { opacity: 0; translate: 0 2rem; }
  30%  { opacity: 1; translate: 0 0; }
  70%  { opacity: 1; translate: 0 0; }
  100% { opacity: 0; translate: 0 -2rem; }
}

Fewer moving parts, no fill-mode puzzle, and those two identical keyframes in the middle are the hold. The cost is that the two halves stop being independently tunable, and the stops are percentages of cover, so the same four numbers give a tall element a long hold and a short one a hurried arrival. Reach for this when the whole thing is one gesture, and for the pair of ranges when the arrival and the departure are two separate ideas.

One note on why an exit is even reasonable here. A triggered outro is usually a mistake, because by the time it plays the viewport has moved on and nobody is looking at it. A scrubbed one cannot have that problem. It is tied to the scroll position, so it plays exactly as the element leaves, at whatever speed the visitor is leaving at, and it runs backwards if they change their mind.

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 { translate: 0 -8%; }
  to   { translate: 0 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.

Now the whole document

Everything so far has measured one element. The other timeline measures a scroll container instead, and does not care where any particular element is. That is the one you want for anything page-wide: a reading progress bar, a chapter rail, a colour that shifts across a long document.

Diagram on a blue-to-tan gradient headed "scroll() measures the scroller, not any element.", under a kicker reading THE WHOLE DOCUMENT and the line "0% when the page is at the top, 100% when it is at the bottom, and it does not care where anything on the page happens to be." A white panel holds three small illustrations of the same tall grey page, captioned top, halfway and bottom, each with lines of placeholder text and a black-bordered window labelled SCREEN positioned at the top, the middle and the bottom of the page in turn. Under each page sits a progress bar filled to 0%, 50% and 100% to match. Beside them is a code snippet: a rule for .progress_bar setting animation to grow linear both and animation-timeline to scroll(root block), followed by the note that root is doing real work there, because the bar is position: fixed, so it has no useful scrolling ancestor and nearest would find nothing. A white strip along the bottom is labelled ITS TWO ARGUMENTS and reads: The scroller: nearest, root or self. The axis: block, inline, x or y. Both optional, and scroll() on its own means nearest block.
Not an element crossing the screen. The screen travelling down the 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, and there is no animation-range on it at all, because the whole document is the range. scroll() 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 lets a fixed element read a page it is not really inside.

Naming a timeline, 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 in neither 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.

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, and it is the fill mode from the very first example coming back to bite. Your animation runs with 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: 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: 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, translate, 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. That first reveal 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