Kevin Powell's pure-CSS view() example from a [software] email, as a playground: Original, Range Lab, Effects, Landing page

What it is

Pure CSS animations that run off scroll position instead of time. An element gets animation-timeline: view(), so its animation advances while the element crosses the screen, and animation-range picks which part of that crossing drives it (entry, cover, contain, exit, plus a percentage). No JavaScript, no IntersectionObserver.

Use it

.item {
  animation: fade-in-out linear both;
  animation-timeline: view();               /* progress = the item crossing the screen */
  animation-range: entry 20% cover 120%;    /* which part of that crossing drives it */
}

entry (coming in), exit (going out), contain (fully on screen), cover (any part on screen), each with a percentage. For a value tied to the whole page instead of one element, use animation-timeline: scroll(). Range Lab shows what each range does as you scroll. Works in Chrome, Edge and Safari 26+; Firefox only behind a flag, so keep the content readable without the animation.

The screens

Screen What it shows
Original The email's HTML word for word: the numbered list fades in and out (entry 20% cover 120%).
Range Lab Pick the range start and end, the keyframes and the timing; the CSS is printed live, and every item shows its own progress (0-100%) through the chosen range.
Effects Seven uses of the same idea, each card with its CSS: fade up, clip-path wipe, blur in and out with range names inside @keyframes, 3D tilt, a number that counts up, colour over the whole pass, stagger by range instead of delay. A scroll() progress bar on top.
Landing page The original list inside a whole page: header that shrinks over the first 240px (scroll(root)), hero that sinks as it leaves (exit), a sideways card track driven by vertical scroll (named view-timeline + contain), a call to action that scales in.

Worth knowing

  • A scroll value can be printed with no JavaScript: register an integer with @property, animate it on the same timeline, and print it with counter-reset: p var(--p); content: counter(p). Range Lab's readouts and the Effects count-up both use this.
  • When the item fades, anything inside it fades too. To keep a readout sharp, name the timeline on the item (view-timeline: --item block) and animate the children against --item instead of the item itself. This behaves the same as view() on the item.
  • Range names can go inside @keyframes (entry 0%, exit 100%), so one animation can do an in-and-out without animation-range (Effects card 3).
  • A sideways track needs a tall parent with a named timeline and a sticky child; contain 0% to contain 100% is exactly the time the sticky child is pinned.
  • @supports not (animation-timeline: view()) shows a notice (and in Landing page, lays the track out as a normal side-scroller); prefers-reduced-motion stops every animation and shows content at rest.