Skip to content

Why motion needs a vocabulary

6 min read

A designer asks for a menu that feels quick, and the engineer hears 150 milliseconds. Shared words turn that guess into a decision both can check.

Figure 1. Scroll position mapped to progress

Scroll

Scroll-linked

An animation whose progress is tied directly to the scroll position, moving forward and backward as you scroll.

Scroll-linked motion has no duration and no easing. Its progress is a number from 0 to 1, measured over the whole page or over the stretch where one element passes through the viewport, and that number is mapped straight onto a property. Scroll down and it moves forward; scroll back up and it runs backwards, exactly as fast as you scroll. CSS can do this on its own with scroll-driven animations.

Also called scroll-driven animation, scrubbing, scroll timeline, tied to scroll position, progress follows the scrollbar, scroll scrubbed

Use when

  • A reading progress bar on long articles and docs.
  • An element tied to its own pass through the viewport, like a figure that settles into place as it scrolls in and undoes it when the reader scrolls back.
  • Storytelling sections where the scroll position is the timeline and the reader sets the pace in both directions.

Avoid when

  • Entrances that should finish on their own once seen. That is scroll-triggered.
  • Large movement or scaling that is out of step with the scroll. It can make people with vestibular disorders feel unwell.
  • Text people need to read at a given moment. A fade tied to scroll leaves it half transparent wherever the reader stops.

Prompt

Add a reading progress bar that follows the scroll position of the page.

- Progress = `scrollTop / (scrollHeight - clientHeight)` of the page scroll (`document.scrollingElement`), clamped 0..1; 0 if the page does not scroll. No transition, easing, delay or smoothing.
- For a nested scroller, use its own scrollTop/scrollHeight and `scroll(nearest)`.
- Bar: 2px tall, fixed across the top, full width, `z-index: 50`, `pointer-events: none`, `aria-hidden`, `transform-origin: left`, `transform: scaleX(progress)` (base `scaleX(0)`), `will-change: transform`. Never animate width.
- CSS route: `@keyframes progress` from `scaleX(0)` to `scaleX(1)`; `animation: progress linear both`, then `animation-timeline: scroll(root)`; shorthand first, it resets the timeline.
- Feature-test `CSS.supports('animation-timeline', 'scroll()')` before the bar's first paint and set `data-fallback` on `<html>` when false; then use the JS route. Scope the CSS animation to `html:not([data-fallback])` inside `@supports (animation-timeline: scroll())`.
- JS route: passive scroll listener, one requestAnimationFrame, clamp, write the transform to the element (not framework state). Also recompute on `resize` and a `ResizeObserver` on `document.body`; scroll alone goes stale.
- Page not scrollable: bar at 0. Put `.is-linked` on the bar in the markup and scope the CSS animation to it; JS toggles it from `scrollHeight > clientHeight` on `resize`/`ResizeObserver`; the JS route writes `scaleX(0)`.
- Reduced motion: no override.

Acceptance checklist

  • At 50% of the page, the bar spans half the scroll container's width (clientWidth).
  • At the very top the bar is empty; at the very end it is full. On a page too short to scroll it stays empty.
  • Scrolling back up shrinks the bar at the same rate; it never replays forward or snaps.
  • No transition, delay or smoothing on the element: once the scroll handlers have run, it matches the scroll position in the same frame.
  • After resizing the window or loading more content, progress still matches the scroll position.
  • Only transform changes; nothing around the bar moves.
  • With reduced motion on, the bar still tracks the scroll position.
  • In a browser where `CSS.supports('animation-timeline', 'scroll()')` is false (Firefox today), the JS route gives the same result. To check in Chromium, stub `CSS.supports` to return false before the page loads.

Code

.progress {
  position: fixed;
  inset: 0 0 auto;
  z-index: 50; /* above the page header */
  height: 2px;
  background: currentColor;
  transform-origin: left;
  transform: scaleX(0); /* empty until a timeline or the JS drives it */
  will-change: transform;
  pointer-events: none;
}

/* Same test as the JS tab, which sets data-fallback when it takes over.
   .is-linked is in the markup; the JS tab drops it while the page cannot
   scroll, so the bar empties instead of holding its last value. */
@supports (animation-timeline: scroll()) {
  html:not([data-fallback]) .progress.is-linked {
    /* The shorthand resets animation-timeline, so it comes first. linear maps
       progress 1:1 (the default is ease); both holds the ends outside the range. */
    animation: progress linear both;
    /* A nested scroller: use scroll(nearest) on an element inside it. */
    animation-timeline: scroll(root);
  }
}

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

/* prefers-reduced-motion: no override.
   House rule: a progress bar keeps tracking under reduced motion; its position is the information. */

Accessibility

Keep scroll-linked movement small and in step with the scroll: large motion out of step with it can make people with vestibular disorders unwell. Under reduced motion, show decorative effects in their final state. A reading progress bar keeps tracking, since its position is the information (a house rule). It repeats what the scrollbar already says, so hide it from screen readers with aria-hidden.

  • Scroll-triggered

    An animation that plays once when an element scrolls into view.

  • Scroll Progress IndicatorSoon

    A bar or ring that fills as you move through the page, showing how much is left.

  • Parallax

    Layers move at different speeds while you scroll, which makes the page feel like it has depth.

Often confused with

  • Scroll-linked vs ParallaxParallax is one use of scroll-linked motion: layers move at different speeds to suggest depth. Scroll-linked is the general technique of tying any property to the scroll position.
  • Scroll-linked vs Scroll-triggeredScroll-triggered plays once on its own clock when something enters view. Scroll-linked has no clock: its progress is the scroll position, so it runs backwards when you scroll up.