scroll() vs view(): Choosing the Right Timeline Function

Every scroll-driven animation starts with the same fork in the road: does progress track how far a container has scrolled, or where one element sits inside the scroll port? scroll() answers the first question, view() the second, and picking the wrong one is the single most common reason a timeline “doesn’t fire” or fires across the wrong distance. This page extends the Scroll-Timeline API reference with a focused decision framework, the full argument syntax of both functions, worked examples, and the migration path between them — all grounded in the compositor model described in Core Animation Fundamentals & Browser Mechanics.

When to use each timeline function

Both functions create an anonymous timeline — no scroll-timeline-name declaration, no extra property on the scroll container. The difference is what the 0-to-1 progress fraction measures.

Choose scroll() when the effect belongs to the scroller as a whole:

  • Reading progress bars and scroll-linked meters — progress must equal scrolled distance Ă· maximum scroll distance, regardless of which elements are visible.
  • Fixed or sticky chrome — headers that shrink, back-to-top buttons that fade in, navigation rails that highlight. These elements never travel through the scroll port themselves, so a visibility-based timeline has nothing to measure.
  • Horizontal galleries driven by vertical scroll — one continuous mapping from total page scroll to a translateX() value.
  • Anything with pixel-addressed ranges — animation-range: 0px 200px is meaningful against a scroller’s offset; it has no visibility equivalent.

Choose view() when the effect belongs to one element’s journey:

  • Reveal-on-scroll cards, images, and headings — each instance animates independently as it enters, without per-element math.
  • Per-element parallax and depth offsets — the offset should be a function of where the element is on screen, not of document scroll position.
  • List items in a long feed — with scroll() you would need to compute each item’s start offset by hand; view() gives every item its own correctly-positioned window for free.
  • Effects that must survive layout changes — view() progress is derived from the element’s layout box, so inserting content above the element does not desynchronize the animation the way hard-coded scroll() pixel ranges do.

A useful mental model: scroll() is scroller-based — you name a scroller and an axis, and the subject of measurement is the scroller’s own offset. view() is subject-based — the element carrying the animation-timeline declaration is itself the subject, and the nearest scrolling ancestor is discovered implicitly. This is the same visibility model that replaces threshold observers, compared in depth in CSS scroll-driven animations vs IntersectionObserver — except the output is a continuous fraction rather than a boolean crossing.

scroll() vs view(): what each function maps to animation progress Two panels. The left panel shows a scroll container with a scrollbar; the scrollbar thumb position maps down to a horizontal progress bar labelled 0 to 1, captioned progress equals scrolled distance divided by max scroll. The right panel shows a scroll port with a subject element entering from the bottom; the subject's position maps down to its own progress bar, captioned progress equals the subject's travel across the scroll port from entry to exit. scroll() — scroller-based view() — subject-based start end 0 1 progress = scrolled distance / max scroll scroll port subject entry exit travel 0 1 progress = subject's travel across the scroll port

Syntax: scroller-based vs subject-based

The two functions take different arguments because they answer different questions. scroll() needs to know which scroller; view() needs to know which slice of the scroll port counts as visible.

/* scroll( <scroller>? <axis>? ) — both arguments optional, any order */
animation-timeline: scroll();               /* nearest scroll container, block axis */
animation-timeline: scroll(root);           /* the document viewport */
animation-timeline: scroll(self inline);    /* the element's own horizontal scroll */
animation-timeline: scroll(nearest y);      /* physical vertical axis */

/* view( <axis>? <inset>? ) — no scroller argument; the element is the subject */
animation-timeline: view();                 /* block axis, auto inset */
animation-timeline: view(inline);           /* horizontal visibility tracking */
animation-timeline: view(block 100px);      /* shrink the tracked area by 100px on both ends */
animation-timeline: view(20% auto);         /* asymmetric: 20% start inset, auto end inset */

Three things trip people up:

  1. view() has no scroller argument. The scroller is always the nearest scrolling ancestor of the subject. If you need visibility tracking against a specific container elsewhere in the tree, you must switch to a named view-timeline hoisted with timeline-scope, covered in the guide to named timelines and timeline-scope.
  2. scroll(self) refers to the element’s own scroll offset — useful for scrollable panels that animate their own border or shadow — while view() is always about the element as cargo inside someone else’s scroller. There is no view(self).
  3. The <inset> adjusts geometry, not range. view(block 100px) moves the effective edges of the scroll port inward by 100px before entry/exit are computed. It combines with animation-range, and negative insets are valid: view(block -50px) starts the timeline 50px before the subject actually intersects the scroll port — a cheap pre-load lead-in.

Implementation

Step 1 — A scroll()-driven example: fade in a back-to-top button

The button is position: fixed, so it never travels through the viewport — a visibility timeline is the wrong tool. Bind it to the root scroller’s progress instead:

@keyframes appear {
  from { opacity: 0; transform: translateY(8px); pointer-events: none; }
  to   { opacity: 1; transform: translateY(0);   pointer-events: auto; }
}

.back-to-top {
  position: fixed;
  inset-block-end: 1.5rem;
  inset-inline-end: 1.5rem;
  animation: appear linear both;
  /* root scroller, block axis — the whole document's progress */
  animation-timeline: scroll(root block);
  /* run the fade across the first 5–15% of total scroll */
  animation-range: 5% 15%;
}

The percentages address the scroller’s total scrollable distance. On a longer page, “15%” is further down in pixels — which is exactly the behavior you want for chrome that reacts to how committed the user is to scrolling, not to any particular element.

Step 2 — A view()-driven example: reveal cards as they enter

Each card owns its timeline; no coordinates, no loops:

@keyframes card-reveal {
  from { opacity: 0; transform: translateY(32px); }
  to   { opacity: 1; transform: translateY(0); }
}

.card {
  animation: card-reveal linear both;
  animation-timeline: view(block);
  /* animate only while the card is entering the scroll port */
  animation-range: entry 0% entry 100%;
}

Fifty cards get fifty independent, correctly-phased animations from one rule. Reproducing this with scroll() would require computing each card’s document offset — and recomputing it whenever content above changes height.

Step 3 — Scope the view() window with animation-range

animation-range is where view() earns its keep. The named range keywords (entry, exit, cover, contain) are only meaningful against a view timeline, because they describe phases of the subject’s journey:

.hero-figure {
  animation: parallax-drift linear both;
  animation-timeline: view();
  /* start when the figure begins entering; finish when it half-covers the port */
  animation-range: entry 0% cover 50%;
}

/* Longhand form, useful when start and end come from different phases */
.caption {
  animation-range-start: contain 0%;  /* fully inside the scroll port */
  animation-range-end: exit 0%;       /* about to start leaving */
}

Against a scroll() timeline, those keywords have nothing to describe — the scroller has no entry or exit — so the range falls back to plain percentages of scroll distance. If you write animation-range: entry 0% cover 50% on a scroll() timeline and wonder why it behaves like normal, this is why. The full keyword table lives in the parent API reference.

Step 4 — Migrating between the two functions

From scroll() to view() — typically when a section-scoped effect was faked with hand-tuned pixel ranges:

/* Before: brittle — breaks when anything above .feature changes height */
.feature {
  animation-timeline: scroll(root);
  animation-range: 1200px 1800px;
}

/* After: self-locating — the range is derived from the element's own box */
.feature {
  animation-timeline: view();
  animation-range: entry 0% cover 60%;
}

Translate ranges by intent, not arithmetic: “starts as it appears” → entry 0%, “done by mid-screen” → cover 50%, “runs while fully visible” → contain 0% to contain 100%.

From view() to scroll() — typically when the animated element stops traveling (it becomes sticky or fixed) or the effect grows to span the whole page. Because the progress domain changes from “this element’s travel” to “total scroll”, re-derive the range from the design requirement rather than porting numbers. If the element must react to a different container’s scroll rather than its own nearest ancestor, reach for a named scroll-timeline instead of the anonymous function.

Verification

  1. DevTools → Animations drawer. Trigger the animation, then select it: Chromium labels the timeline ScrollTimeline or ViewTimeline. Seeing ScrollTimeline where you expected ViewTimeline means the view() declaration was dropped or overridden — check for an animation shorthand written after animation-timeline, which resets it.
  2. Scrub the range. The drawer’s scrubber maps to scroll positions. For view(), confirm progress 0 coincides with the subject’s leading edge at the scroll-port edge (plus any inset).
  3. Console progress probe:
const anim = document.querySelector('.card').getAnimations()[0];
// A percentage CSSUnitValue for scroll-driven timelines, not milliseconds
console.log(anim.timeline.currentTime?.toString()); // e.g. "37.5%"
// ViewTimeline instances additionally expose the subject element
console.log(anim.timeline.subject ?? 'scroll timeline — no subject');
  1. Performance panel. Record a scroll pass and confirm no Layout or Recalculate Style events fire per frame — both functions should interpolate on the compositor when the keyframes stick to transform and opacity, per the rendering pipeline for scroll animations.

Edge cases and gotchas

Nested scrollers resolve nearest surprisingly. scroll(nearest) and view() both bind to the nearest scrollable ancestor — which may be an overflow: auto wrapper a framework or CSS reset introduced, not the document. Symptom: the animation completes within a small inner panel’s scroll range, or never starts because the inner scroller has no overflow. Fix: be explicit (scroll(root)) or use a named timeline on the intended container.

overflow: hidden still counts as a scroll container. A hidden box is programmatically scrollable, so it participates in nearest resolution even though the user cannot scroll it — the timeline attaches and then sits frozen at progress 0. Use overflow: clip on purely decorative clipping wrappers; clip creates no scroll container and is skipped during resolution.

position: fixed subjects make view() inert. A fixed element does not move relative to the viewport, so its view timeline never advances — the animation holds whatever animation-fill-mode dictates. The same applies to a position: sticky element while it is stuck: progress stalls for the stuck span, producing a plateau in the middle of the animation. Drive fixed/sticky chrome with scroll() (as in Step 1) and keep view() for elements that genuinely travel.

An inactive timeline means no animation, not progress 0. If the resolved scroller cannot scroll on the queried axis — content fits, or the axis is wrong (view(inline) in a vertical feed) — the timeline is inactive and the animation does not apply at all. Elements styled to start at opacity: 0 inside the keyframes remain visible; elements hidden by a static rule outside the animation stay invisible. Always put the safe resting state in static CSS and let the animation own only the motion.

Transforms don’t move the timeline. Both functions measure the layout box. A subject shifted by transform: translateY(-200px) still enters the timeline where its untransformed box does. This is what makes compositor execution possible — but it means “visually on screen” and “timeline says on screen” can disagree when large transforms are in play.

Browser-specific notes

Chrome / Edge 115+. Both functions, all axes and insets, and the named-range keywords shipped together in Chromium 115. Older Chromium builds had shorthand-parsing quirks around single-keyword animation-range values; prefer the explicit two-value form.

Firefox. The full feature set exists behind the layout.css.scroll-driven-animations.enabled pref (Nightly). Unflagged Firefox ignores animation-timeline, so the animation runs as a normal time-driven animation unless guarded — always wrap scroll-driven declarations in @supports (animation-timeline: scroll()).

Safari. Scroll-driven animation support is in active development; treat Technology Preview behavior as unstable and keep the @supports guard plus a static fallback in place. Where Safari coverage is a hard requirement today, the scroll-timeline polyfill approach bridges both scroll() and view() semantics with a main-thread fallback — test it against your nested-scroller cases specifically, since nearest resolution is where polyfill and native behavior most often diverge.

Up: Understanding the CSS Scroll-Timeline API