View Timelines and animation-range
Scroll-driven animations come with two timelines. A scroll timeline measures how far a container has been scrolled; a view timeline measures how far one element has travelled through the visible part of its scroller. Most interface effects — reveals, parallax, image zooms, progress markers for a section — care about an element's journey, so the view timeline is the one you reach for most. Its power, and its confusion, lies in animation-range: the property that decides which part of that journey the animation occupies. This page explains exactly what the named ranges measure, how offsets inside them work, and how to choose ranges that complete while the user can see them. It belongs to Scroll-Driven Animations in the CSS-Only Micro-Interactions & Animations guide.
What a view timeline measures
A view timeline runs from 0% to 100% as the tracked element (the subject) crosses the scrollport of its nearest scroll container. The whole crossing is divided into phases named after what the subject is doing:
- entry — the subject's leading edge has entered, but its trailing edge has not yet.
- contain — the subject is fully inside the scrollport (or, if it is taller than the scrollport, fully covering it).
- exit — the subject's leading edge has left, its trailing edge has not yet.
- cover — the whole journey, from the first pixel entering to the last pixel leaving.
Two more ranges, entry-crossing and exit-crossing, measure the subject's own edge crossing the scrollport edge; they differ from entry and exit only when the subject is taller than the scrollport.
The complete implementation
The demo below applies three different ranges to three identical cards so the difference is visible side by side. Scroll inside the frame and watch when each bar fills.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>animation-range compared</title>
<style>
body { margin: 0; font: 15px/1.5 system-ui, sans-serif; }
.spacer { block-size: 80vh; }
.card {
margin: 1rem;
padding: 1rem;
border: 1px solid #cbd5e1;
border-radius: 10px;
}
.card .bar {
block-size: 8px;
border-radius: 4px;
background: #2563eb;
transform-origin: left;
scale: 0 1;
}
@supports (animation-timeline: view()) {
.card .bar {
animation: fill linear both;
animation-timeline: view();
}
/* The bar is inside the card, but view() tracks the bar itself.
Ranges below are measured against the bar's journey. */
.card--cover .bar { animation-range: cover; }
.card--entry .bar { animation-range: entry; }
.card--contain .bar { animation-range: contain; }
@keyframes fill { to { scale: 1 1; } }
}
</style>
</head>
<body>
<div class="spacer"></div>
<div class="card card--cover"><strong>cover</strong><div class="bar"></div></div>
<div class="card card--entry"><strong>entry</strong><div class="bar"></div></div>
<div class="card card--contain"><strong>contain</strong><div class="bar"></div></div>
<div class="spacer"></div>
</body>
</html>
With cover, the bar fills gradually across its entire trip and is only half full when centred. With entry, it fills during the brief moment it crosses the bottom edge — for an 8-pixel bar, almost instantly. With contain, it fills while it is fully visible, which for a small subject is nearly the whole trip. The subject's size changes how long each range lasts, which is why real reveals usually track the whole card rather than a tiny child.
The key technique: range offsets
Each end of animation-range is a range name plus an optional percentage within that range. entry 0% is the start of entry; entry 100% is its end, the moment the subject is fully visible. You can start in one range and end in another:
.reveal {
animation: reveal linear both;
animation-timeline: view();
/* Start as the element begins to enter;
finish when it is a quarter of the way through the whole journey. */
animation-range: entry 0% cover 25%;
}
Offsets can also be lengths: entry 2rem starts once two rems of the subject are visible, which is handy for sticking to the same visual moment regardless of element size. The shorthand accepts one or two values: animation-range: entry means entry 0% entry 100%, and the longhands animation-range-start and animation-range-end set each end separately.
Because an animation's keyframes are mapped only onto the chosen slice, fill mode matters. Without both (or at least backwards and forwards), the element snaps back to its unanimated style outside the range — a revealed card would vanish again before it leaves the screen. animation-fill-mode Explained covers the four modes.
Choosing ranges that feel right
A few defaults cover most effects:
- Reveals:
entry 10% cover 30%. The animation starts once a sliver is visible, so there is never a blank flash, and finishes well before the element is centred, so the user reads settled content. - Parallax:
cover. The layer should move across the entire trip; clipping the range makes the motion start and stop abruptly. See Scroll-Driven Parallax Effects. - Exit fades:
exit 0% exit 80%. Fade out as the element leaves, completing before the last pixel goes. - Section progress:
containon a tall section, so a progress bar fills while the section occupies the screen.
Avoid ranges that finish late in the journey for anything carrying content. If a card only becomes fully opaque at cover 60%, someone reading at the bottom of the screen sees a half-faded card for most of the time they look at it.
Variation: a named timeline for another element
view() animates the element it is declared on. To drive a different element — a table of contents entry highlighting while its section is on screen, or a caption reacting to an image — name the timeline on the subject and reference it elsewhere:
.chapter {
view-timeline: --chapter block;
}
.chapter .chapter__marker {
animation: highlight linear both;
animation-timeline: --chapter;
animation-range: contain;
}
The animating element must be a descendant of the subject, or share an ancestor on which timeline-scope: --chapter hoists the name. A view-timeline-inset shorthand value or the inset argument of view() shrinks the area considered visible, which compensates for a sticky header covering the top of the scroller.
Debugging ranges
Ranges are invisible, so debugging by feel is slow. Chromium's DevTools Animations panel shows scroll-driven animations with their ranges; scrubbing the page shows the playhead move. A faster low-tech check is a temporary keyframe that changes background-color sharply at 50%: scroll and watch where the colour flips. If it flips earlier or later than expected, the subject is probably a different size than you assumed, or the nearest scroller is not the one you think — a stray overflow: hidden on an ancestor creates a scroll container and captures the timeline. When nothing animates at all, confirm the scroller is actually scrollable; a timeline whose scroller has no overflow is inactive and the animation stays at its unanimated state.
Reduced motion and ranges
A range does not change whether an effect is motion; it only changes when the motion happens. Reveals that slide, scale or rotate should still sit inside @media (prefers-reduced-motion: no-preference), or fall back to an opacity-only version. One subtlety is specific to scroll-driven effects: because the user controls the playhead with their own scrolling, the motion is less surprising than a time-based animation, but it still moves content in a direction that differs from the scroll, which is exactly the mismatch that troubles people with vestibular sensitivity. Keep reduced-motion handling identical to time-based effects rather than treating scroll-linked motion as exempt.
Browser support
animation-timeline and animation-range are supported in Chrome and Edge 115+ and Safari 26+. timeline-scope is supported in Chrome and Edge 116+ and Safari 26+. In Firefox, scroll-driven animations are available only as a preview behind a flag, not in stable releases, so every range-based effect should sit inside @supports (animation-timeline: view()) with a static fallback outside it. Scroll-Driven Animation Fallbacks shows the patterns.
FAQ
What is the difference between scroll() and view()?scroll() tracks how far a scroll container has been scrolled, from top to bottom. view() tracks how far one element has travelled through the visible part of its scroller. Use scroll() for page-level progress and view() for effects tied to an element appearing.
What do entry and exit mean in animation-range?entry is the part of the journey where the element is crossing into view, from its first pixel appearing to fully inside. exit is the mirror image as it leaves. cover spans the whole journey and contain the part where it is fully visible.
Why does my view() animation finish too early or too late?
The default range is cover, which starts when the first pixel enters and ends when the last pixel leaves. For a reveal, narrow it with animation-range: entry 0% entry 100% or entry 10% cover 40% so the animation completes while the element is on screen.
When do I need view-timeline-name instead of view()?view() only drives the element it is declared on. A named view-timeline lets another element, such as a sibling or a caption, animate based on the tracked element's progress. Use timeline-scope when the animating element is not a descendant.
Related
- Scroll-Driven Animations — the parent guide.
- Scroll-Triggered Reveal Animations — reveals built on these ranges.
- Scroll Progress Bar Without JavaScript — the scroll() timeline counterpart.
- Scroll-Driven Parallax Effects — the cover range in practice.
Related articles
More pages in the same section.