Core Philosophy
- Scroll position is a clock. A scroll-driven animation is an ordinary
@keyframesrule with the time clock swapped for the scrollbar, so stopping stops it and scrolling up rewinds it - Two different effects hide behind the phrase "scroll animation": scrubbed and one-shot. Choosing the wrong one is the most common bug in this area, and it reads as broken rather than unpolished
- The browser owns scrolling. Your job is to attach motion to it, never to retime it or reimplement it
- Nothing may depend on the animation running. Support is uneven, JavaScript fails, and motion gets reduced, so the base state has to be the readable one
- View transitions live in
view-transitions.md. This doc is the scroll layer and only sketches that API
Scroll-driven or scroll-triggered
Two different effects that get confused constantly.
- Scroll-driven: the animation is scrubbed by scroll position. Stop scrolling and it stops. Scroll up and it reverses. Use it for progress bars, parallax, and anything that should feel physically attached to the page
- Scroll-triggered: crossing a threshold starts a normal time-based animation which then runs to completion on its own. Use it for reveal-on-enter, count-ups, and one-shot entrances with their own choreography
- Scroll-driven has a pure CSS solution. Scroll-triggered does not have a portable one yet, so
IntersectionObserveris still the answer. A CSS-native trigger is being specified, but do not plan around it until it ships in more than one engine - A reveal that scrubs backwards and un-reveals as the user scrolls up is the symptom of picking the scrubbed version for one-shot content
Scroll progress timelines
- Write an ordinary
@keyframesrule, then swap the clock: drop the duration and iteration count, and setanimation-timeline scroll()tracks a scroll container, from the top of its scroll range to the bottom
@keyframes grow-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.reading-progress {
transform-origin: left;
transform: scaleX(0);
}
@supports (animation-timeline: scroll()) {
.reading-progress {
animation: grow-bar 1ms linear both;
animation-timeline: scroll();
}
}
- Declare
animation-timelineafter theanimationshorthand. The shorthand resets it, so the reverse order silently leaves the animation on the document timeline, where it runs once on load and never scrubs - Do not put the timeline inside the
animationshorthand either. A browser that does not support timeline values there throws away the whole declaration, animation included - Keep the rules behind
@supports, so an unsupported browser holds the base state instead of playing the keyframes once on load - Give the animation a token duration such as
1ms. The timeline replaces the clock, but a missing duration is treated as zero where the timeline is not understood - Keep the easing
linear. The user's scroll speed is the easing, and layering an ease on top makes the element lag behind the finger or wheel
Which scroller, which axis
scroll() takes two optional arguments, in either order, and the defaults are the thing that bites.
- The scroller:
nearest(default),root, orself.nearestis the closest ancestor that scrolls, which means an element sitting inside anoverflow: autopanel binds to that panel and quietly animates off the panel's scroll position instead of the page's - The axis:
block(default),inline,y, orx animation-timeline: scroll(root block)forces the document viewport, which is the fix when a component that lives inside a scrolling panel must respond to page scrollview()has no scroller argument at all. A view progress timeline always tracks the subject inside its nearest ancestor scroller, so there is nothing to override
View progress timelines and animation-range
view()tracks a single element's progress through its scroller rather than the whole document. This is the one for reveals, stickers and per-section effectsanimation-rangesets which slice of that journey the animation occupiescoveris the default: from the moment the element starts entering to the moment it fully leavescontainruns only while the element is fully inside the scrollportentryandexitisolate the two ends- The long form takes a range name plus percentages, for example
animation-range: entry 0% entry 150%, which is how you let a reveal finish a little after the element has fully arrived instead of landing exactly on the edge animation-range-startandanimation-range-endare the longhands, and they read far more clearly than the four-value shorthand
.section-card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
- Ranges also take plain lengths and percentages, with no range name.
animation-range: 0px 400pxconfines the animation to an absolute scroll distance, which is exactly what a hero effect that must finish inside the first screen needs, rather than a fraction of a page whose height you do not control
The fill mode is not a preference
Omitting the fill mode causes two specific, named bugs. Keyframe values only apply while the animation is inside its active range, so outside that range the element renders as if the animation did not exist.
- The entering blink.
animation-range: containwith a0% { opacity: 0 }fade leaves the element fully opaque all the way in, then blinks it invisible the instant it becomes fully contained, then fades it in. The0%value was not applied before the range began.backwardsfixes it - The scroll-back flash.
animation-range: exitwith a100% { opacity: 0 }fade drops theopacity: 0the moment the element leaves the range, and there is a gap between re-entering the range and the keyframes being applied again, so the un-faded element flashes when the user scrolls back up.forwardsfixes it bothis simply both fixes at once, which is why almost every scroll-driven animation wants it. Treat a missing fill mode as a bug, not a style choice
Several scroll-driven animations on one element
animation,animation-timelineandanimation-rangeall take comma-separated lists that pair up positionally. That is how one element fades in on entry and out on exit without any JavaScript
@keyframes fadeFromTransparent { 0% { opacity: 0; } }
@keyframes fadeToTransparent { 100% { opacity: 0; } }
.card {
animation: fadeFromTransparent backwards, fadeToTransparent forwards;
animation-timeline: view(), view();
animation-range: entry, exit;
}
- Note the fill modes differ per animation, for the two reasons above: the entry fade needs
backwards, the exit fade needsforwards - Keep the lists the same length. A short list repeats, which pairs animations with the wrong ranges and produces motion nobody wrote
Driving one element from another element's scroll position
- The scroll-linked article pattern: prose scrolls on the left, a graphic on the right updates in step
- Name a timeline on the element that provides the progress, then reference that name from the element that animates
.article-layout {
timeline-scope: --step;
}
.prose-step {
view-timeline: --step;
}
.chart {
animation: highlight-cluster 1ms linear both;
animation-timeline: --step;
animation-range: contain;
}
- A named timeline is only visible to that element and its descendants. If the animated element sits elsewhere in the tree, declare
timeline-scopeon a common ancestor to hoist the name up - This is the piece people miss, and it fails silently: no error, the animation simply never runs
timeline-scopeis also the piece the scroll-timeline polyfill does not implement, so a hoisted timeline is the one scroll technique you cannot polyfill
Staggering the pieces of a heading
- A per-word reveal is a stack of
<span>s inside one heading, and the naive version animates every word identically - The cause is the inline box. Engines have shipped inline subjects taking their view progress from the containing block rather than from the span itself, so all five words share one timeline and one progress value. The spec scopes a view timeline to the subject's own principal box, but inline subjects are a known interoperability rough edge, so do not rely on either behaviour holding
- Fix one, per-word timelines: give each piece
display: inline-blockso it generates its own block-level box and its own progress - Fix two, deliberate: leave the pieces inline, accept that they all share the parent's timeline, and stagger them by giving each its own
animation-rangeagainst that shared progress. This is the version that survives line breaks, because the stagger comes from the range rather than from where the word happens to sit sibling-index()generates the offsets without writing one selector per child
.word {
animation: fade-in linear both;
animation-timeline: view();
animation-range:
cover calc(20% + sibling-index() * 6%)
cover calc(50% + sibling-index() * 6%);
}
sibling-index()is new. Where it is not supported the wholeanimation-rangedeclaration is invalid and every word falls back to the fullcoverrange, which fades the heading in as one block: degraded, not broken- The verbose form, one
:nth-child()selector per word with a hardcoded range, is the portable one. Use it when the stagger is load-bearing and the number of pieces is fixed
Parallax
- Parallax is layers moving at different rates to imply depth. Scroll timelines make it a few lines of CSS with no scroll listener
- Give every layer the same keyframes and the same timeline, and let the keyframes read the travel distance from a custom property, so one rule serves every layer and each layer sets its own distance where it is declared
@keyframes parallax {
from { translate: 0 var(--travel); }
}
@media (prefers-reduced-motion: no-preference) {
.parallax-layer {
animation: parallax linear;
animation-timeline: scroll();
animation-range: 0px 400px;
}
}
<img class="parallax-layer" src="/sky.png" alt="" style="--travel: 0px" />
<img class="parallax-layer" src="/hills.png" alt="" style="--travel: 24px" />
<img class="parallax-layer" src="/trees.png" alt="" style="--travel: 64px" />
- Background layers travel least, foreground layers travel most. The layers converge at the end of the range, so pick the resting composition first and set each
--travelas the offset it starts from - Keep total travel small. A large offset reads as a broken layout rather than depth
- Parallax is not a grey area for reduced motion. It manufactures the sensation of moving, which is the conflict between eye and inner ear that causes motion sickness, so gate the whole effect behind
prefers-reduced-motion: no-preferenceas above - Reading a custom property inside
@keyframesis valid CSS, but it is one of the places polyfills fall over. If you are relying on a polyfill, test this specific effect rather than assuming
Sticky headers
- A transparent sticky header that appears to change colour per section is usually built with
IntersectionObserverand a class toggle. There is a CSS-only version - Put a sticky blocker element inside each section, sized to the header height and filled with that section's background colour. A sticky element is trapped inside its parent, so as one section scrolls away its blocker leaves with it and the next one takes over
- The handoff is exact because it is layout, not a listener, so there is no flicker at the boundary and no work on the scroll thread
header {
position: fixed;
top: 0;
z-index: 1;
height: var(--header-height);
}
.hero > .blocker,
.main-content > .blocker {
position: sticky;
top: 0;
height: var(--header-height);
}
.hero > .blocker { background: oklch(0.92 0.04 250); }
.main-content > .blocker { background: oklch(1 0 0); }
- Mount the header with
position: fixed, notposition: sticky. A sticky element still occupies layout, so a sticky header reserves an empty band above the hero and the blockers then cover the wrong thing - If the header must be sticky for other reasons, pull the following section up by the header height:
.hero { margin-top: calc(var(--header-height) * -1); } - The cost of the whole trick: each section needs enough vertical room to hide its blocker. A section that opens with a full-bleed illustration cannot use it and falls back to an observer
Do not hand a transitioned property to a scroll animation
- Keyframe values override every normal declaration. Only
!importantvalues and values currently being transitioned outrank them, so a property a scroll-driven animation is writing is effectively no longer yours - The concrete regression: moving a sticky header's background from an observer class toggle to a scroll-driven animation removes the hard jump at the section boundary and looks better, and it silently kills the theme toggle that used to cross-fade that same background
- Decide which of the two behaviours matters more, or move them onto different properties. Letting the scroll animation drive the
opacityof a tinted overlay layer leavesbackground-colorfree for the theme transition
Scroll-triggered reveals
IntersectionObserverwatches an element against a root box and reports when the overlap crosses a threshold- Add a class and let CSS own the animation. Never animate from inside the callback
- Ship the visible state as the default and let the observer remove it, so the content is readable if JavaScript never runs
- Unobserve after the first trigger for one-shot reveals, otherwise every scroll past re-runs it
const reveal = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-revealed");
reveal.unobserve(entry.target);
}
},
{ threshold: 1, rootMargin: "0px 0px -32px 0px" },
);
document.querySelectorAll(".reveal").forEach((el) => reveal.observe(el));
Three things about the options account for most of the confusion.
- The callback fires once on
observe(), reporting current state, not only when the element crosses an edge. Treating that first call as a crossing is why reveals look like they fired early, and why elements already on screen at load animate for no reason. Branch onentry.isIntersectingrather than on the call happening thresholdis a fraction between 0 and 1 of the observed element that is inside the root box.0, the default, fires on a single pixel.1fires only when the element is fully inside. Values outside that range throw aRangeError, so there is no way to say "wait until it is past fully visible" with the threshold alonerootMargingrows or shrinks the root box using CSS margin shorthand order, inpxor%only. Positive values expand it so the trigger fires early, negative values inset it so it fires late. This is the delaythresholdcannot express. Inset the edge the element is crossing: the-32pxbottom value above makes content rising from the bottom wait until it is that far clear of the viewport edge, and a negative top value does the same for content approaching the top
Two caveats on rootMargin. Inside an iframe with the implicit root, the margin expands the top-level viewport rectangle and the iframe's own boundary still clips unaffected, so pass an explicit root inside the frame. For a cross-origin target the margin is ignored entirely.
Triggering on how far down the page the user is
- There is no view-timeline equivalent of "fire once the user has read half the article". View timelines answer where an element is, not how far through the document the reader is
- Use a throttled scroll listener that computes scroll position over scrollable height
const onScroll = throttle(() => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
if (scrollable <= 0) return;
const progress = window.scrollY / scrollable;
newsletterPrompt.classList.toggle("is-visible", progress >= 0.5);
}, 100);
window.addEventListener("scroll", onScroll, { passive: true });
- Throttle it and mark the listener
passive, so a slow handler can never hold up the scroll thread - This is a trigger, not a scrub. Anything that should track the scrollbar continuously belongs on a
scroll()timeline instead, where it runs off the main thread
Do not replace native scrolling
- Smooth-scrolling libraries that intercept the wheel and add inertia take control away from the user
- People scroll with wheels, trackpads, touch, keyboard and the scrollbar thumb, and each has its own feel that the operating system already tuned. Overriding it blurs precise gestures into a lag
- It is also an accessibility hazard: added inertia is exactly the motion that triggers symptoms for vestibular disorders, and these libraries rarely respect
prefers-reduced-motion scroll-behavior: smoothon an anchor jump is fine. That is one deliberate movement, not a permanent change to how scrolling feels
The test that settles the argument
Arguing about whether something counts as scrolljacking goes nowhere. Ask one question instead: can the user still scroll the way they do on every other site, with their own device?
- Replacing wheel handling, adding inertia, or looping the content endlessly so there is no top or bottom: fails
- Translating vertical wheel input into horizontal movement of the whole page: fails
- Elements becoming sticky or fixed for part of a scroll while the main content scrolls through normally: passes, because the scroll itself is untouched
Use scroll as an input surface
When an interface maps scrolling to a gallery, timeline, or other visual state, preserve the browser's native input model instead of translating raw wheel deltas.
- Map scroll position to the visual property. A horizontal gallery may still accept ordinary vertical document scrolling when horizontal wheel input would be unfamiliar or unavailable
- Derive the scrollable extent from content. An arbitrary document height will drift when the number or size of items changes
- Use
scrollendto settle on the nearest semantic item when it is available, with a short scroll-idle timer as the fallback - If both axes are accepted, lock to the first meaningful axis for the rest of the gesture so diagonal noise does not make the content wobble
- Interrupt programmatic snapping as soon as new wheel, touch, pointer, or keyboard input arrives. Input always takes ownership back from animation
function settleToNearestItem(scroller: HTMLElement, itemStep: number) {
const index = Math.round(scroller.scrollLeft / itemStep);
scroller.scrollTo({
left: index * itemStep,
behavior: matchMedia("(prefers-reduced-motion: no-preference)").matches
? "smooth"
: "auto",
});
}
const settle = () => settleToNearestItem(scroller, itemStep);
if ("onscrollend" in scroller) {
scroller.addEventListener("scrollend", settle);
} else {
let settleTimer: ReturnType<typeof setTimeout>;
scroller.addEventListener("scroll", () => {
clearTimeout(settleTimer);
settleTimer = setTimeout(settle, 120);
}, { passive: true });
}
CSS scroll snap
To prevent scroll sections from stopping in the middle, a few lines of CSS lock them into place. The snap points live on the scroll container, the alignment lives on the children, and the browser does the settling itself, on the compositor, with native physics
.scroll-container {
overflow-y: scroll;
scroll-snap-type: y mandatory;
}
.section {
scroll-snap-align: start;
}
- This is the declarative version of the
scrollendsettle above. Reach for it first: no listener, no timer fallback, and it passes the scrolljacking test because the browser, not your code, owns the gesture mandatorymeans the scroller always rests on a snap point. That is right for full-viewport sections and carousels where every stop is a slide. It is wrong when a section can be taller than the scrollport, because content between snap points becomes unreachable, and that is a trap, not polish. Useproximitythere, which only snaps when the scroll ends near a pointscroll-snap-align: startaligns each section's top edge with the container's top.centeris the carousel alignment,endthe bottom-anchored one- With a fixed header, pair it with
scroll-padding-topon the container orscroll-snap-stopandscroll-margin-topon the sections, so a snapped section does not settle underneath the header - Snapping changes where scrolling rests, not how it moves, so it needs no reduced-motion gate and no
@supportsguard: an engine without it degrades to plain scrolling
Support and fallbacks
animation-timelineis not in every engine, and it arrived late in the ones that have it, so a real share of installed browsers will not run your scroll-driven animations. Check current support before treating it as a given- That is survivable because the failure mode is good: gate the rules with
@supports (animation-timeline: scroll())and the element holds its base state. Design the base state first and the motion second - The scroll-timeline polyfill covers the common cases and struggles past them. It does not implement
timeline-scope, so hoisted named timelines cannot be polyfilled, and effects that read custom properties from inside@keyframesare worth testing specifically IntersectionObserveris available everywhere and is the fallback for anything that has to work, which is another reason to keep reveals triggered rather than scrubbed
View transitions, briefly
- A view transition is the browser doing the tweening for a discrete change: it screenshots the page before your DOM update, screenshots it after, and animates between the two. You describe the end state and it finds the movement
- Wrap the DOM update in
document.startViewTransition, and nothing else changes about how you write the update
function showNextSlide() {
if (!document.startViewTransition) return update();
document.startViewTransition(() => update());
}
- The default is a cross-fade of the whole page. Everything past that comes from
view-transition-name, which lifts an element out of the page snapshot into its own animated group - It is a state-change tool, not a scroll tool. A transition runs on its own clock for a fixed duration, so it cannot be scrubbed and cannot be reversed halfway. If the user's scroll should drive the motion, you want a timeline from this doc, not a transition
- The two do meet on one surface: a sticky header or nav indicator that both reacts to scroll and survives a route change. Keep them on separate properties so neither is fighting the other for the same value
- Naming rules, morphing one element into another, clipped children escaping their parent, interruptions, cross-document navigation, transition types, React and routers all live in
view-transitions.md. Go there before shipping a transition on a real surface, because the traps are where the time goes - Reduced motion for transitions is its own decision, covered in the same doc: the usual answer is to keep the transition and gate only your custom keyframes, not to skip the API
Reduced motion
- Scroll-linked movement is one of the strongest vestibular triggers, so every scroll animation needs a reduced path
- Wrap the scroll-driven rules in
@media (prefers-reduced-motion: no-preference), as the parallax example above does, so the motion is opt-in and there is nothing to remember to undo.prefers-reduced-motionhas exactly two values, so a browser that cannot evaluate the query matches neither, and this direction leaves that browser with a still page.accessibility.mdhas the argument in full
.section-card {
opacity: 1;
translate: none;
}
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.section-card {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 10% cover 40%;
}
}
}
- Read the ungated rule on its own and ask what the user sees. Here it is the finished state: full opacity, no offset, the card where it belongs. An entrance keyframe that starts at
opacity: 0must never leak into it - A progress bar is the case to check twice. Its base
transform: scaleX(0)is an empty bar, which is fine while the bar is decorative and wrong the moment it carries information. Give it a static resting value that reads correctly, or drop the element - Where you are retrofitting CSS you cannot restructure, a
reduceblock is still the tool, and thereanimation-timelinehas to be reset alongsideanimation. Setting it tononedetaches the element from every timeline, including the document timeline, which is the part a bareanimation: nonecan leave attached
@media (prefers-reduced-motion: reduce) {
.inherited-reveal {
animation: none;
animation-timeline: none;
transform: none;
translate: none;
}
}
- Reduced motion means cutting the movement, not the change. A reveal becomes content that is simply there, a progress bar becomes a static bar or is dropped
- Never gate content on a scroll animation. If a reveal never fires, because the property is unsupported, the observer failed, or motion is reduced, the text must still be present and readable
Common mistakes
- Using a scroll-driven animation for a one-shot reveal, so it scrubs backwards and un-reveals when the user scrolls up. Reveals want a trigger, not a scrubbed timeline
- Declaring
animation-timelinebefore theanimationshorthand, so the shorthand resets it and the animation plays once on load instead of scrubbing - Leaving the fill mode off, which blinks a
containfade as the element becomes fully visible and flashes anexitfade when the user scrolls back up - Assuming
scroll()means the page when the element sits inside anoverflow: autopanel, so the animation tracks the panel and appears frozen - Adding easing to a scroll-scrubbed animation, which makes it feel detached from the input
- Referencing a named timeline from outside its subtree with no
timeline-scope, which fails silently, and expecting a polyfill to cover it - Per-word reveals built from inline spans that all animate together, because the inline pieces are not carrying their own view progress
- Treating the first
IntersectionObservercallback as a crossing, so reveals fire on elements that were already on screen - Reaching for a
thresholdabove 1 to delay a trigger, which throws aRangeError. That delay isrootMargin's job - Handing a property that something else needs to transition, such as a themed background, to a scroll-driven animation
- Reaching for a smooth-scrolling library, which overrides input the user's device already tuned