GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Scroll Animation: 9 View-Timeline Examples

Nine css scroll animation demos, one idea: A card sits blurred, then sharpens as you scroll past it — one range value drives it.

Auto-plays · click a tile to jump to its section · all nine in one zip

Where animation-range Turns a Scroll Position Into a Progress Value

Every demo below reads the same kind of number: a 0-to-1 progress value that animation-timeline: view() produces as an element crosses the scrollable viewport. animation-range: entry narrows that value to just the entry phase — 0 the instant the element's leading edge appears, 1 once it's fully on screen — so items 1, 2, 3, 6, 7, and 9 use it to drive a one-way reveal. animation-range: cover instead spans the whole pass-through, 0 at first contact to 1 at the last pixel leaving, so items 4, 5, and 8 get a value that can rise and fall inside one crossing. Nothing here reads scrollTop directly — the browser recomputes the value every time a frame is painted, and the nine patterns differ only in which CSS property that value is plugged into.

01Card That Grows and Sharpens With the Range Value

As the card's entry range value fills from 0 to 1, its opacity value and size value both grow together, so it appears increasingly sharp and large — a feature card settling into a landing page as you scroll to it.

Card That Grows and Sharpens With the Range Value
Open in a new tab

01 · Card That Grows and Sharpens With the Range Value — Measured area 16.106% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-range: entryanimation-timeline: view()--fade-scale-start
// Native: animation-timeline: view() + animation-range: entry binds the entry
// range value directly to opacity and size.
.supports-vt .fsc__card {
  animation-name: fscEnter;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes fscEnter {
  0%   { opacity: 0; transform: scale(var(--fade-scale-start, .8)); }
  100% { opacity: 1; transform: scale(1); }
}

The one value doing the work is --fade-scale-start: the keyframe only ever interpolates from that custom property to 1, so opacity and scale are locked to the same range value instead of running on two separate timers that could drift apart.

Measured: cumulative area 16.1% of the frame, average intensity 138.8, moved 23/23 frames.

02Thumbnail Revealed as a Boundary Value Moves

While the range value fills, the boundary value of a clip-path: inset() box travels from one edge to the other, uncovering the image little by little — a portfolio thumbnail that finishes revealing itself exactly when it's centered on screen.

Thumbnail Revealed as a Boundary Value Moves
Open in a new tab

02 · Thumbnail Revealed as a Boundary Value Moves — Measured area 17.112% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

clip-path: insetanimation-range: entrycprReveal
.supports-vt .cpr__panel {
  animation-name: cprReveal;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes cprReveal {
  0%   { clip-path: inset(0 100% 0 0); }
  100% { clip-path: inset(0 0% 0 0); }
}

The value here is the right inset percentage: it starts at 100% (the whole box hidden) and counts down to 0%, so the same progress number that would otherwise set an opacity is instead read as a distance across the box.

Measured: cumulative area 17.1% of the frame, average intensity 196.9, moved 23/23 frames.

03Background Image Swept by a Diagonal Boundary Value

Following the range value, a registered custom property sweeps a mask boundary diagonally across the image, revealing it as if a curtain were pulled back at a 135-degree angle.

Background Image Swept by a Diagonal Boundary Value
Open in a new tab

03 · Background Image Swept by a Diagonal Boundary Value — Measured area 21.765% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

@property --wipemask-image135deg
@property --wipe {
  syntax: "<percentage>";
  inherits: true;
  initial-value: 0%;
}
.supports-vt .mwi__pic {
  animation-name: mwiWipe;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes mwiWipe {
  0%   { --wipe: 0%; }
  100% { --wipe: 100%; }
}

--wipe only exists because @property registers it as a <percentage> — without that registration the browser could not interpolate it smoothly, and the mask-image gradient reads that one value twice (once as the hard edge, once twelve points behind it) to keep a soft feather instead of a hard line.

Measured: cumulative area 21.8% of the frame, average intensity 76.3, moved 23/23 frames.

04Card Whose Focus Loosens and Locks With the Range Value

As the element's cover-range value changes, its blur value grows then shrinks, so the focus loosens and locks separately on the way in and on the way out of view — a product card that's sharpest right as it crosses the center of the screen.

Card Whose Focus Loosens and Locks With the Range Value
Open in a new tab

04 · Card Whose Focus Loosens and Locks With the Range Value — Measured area 28.216% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-range: coverfilter: blurhump
.supports-vt .bfs__card {
  animation-name: bfsFocus;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: cover;
}
@keyframes bfsFocus {
  0%   { filter: blur(var(--blur-max, 12px)); opacity: .55; }
  50%  { filter: blur(0px); opacity: 1; }
  100% { filter: blur(var(--blur-max, 12px)); opacity: .55; }
}

The 50% keyframe is the calculation: cover runs 0 to 1 across the full pass-through, and the fallback script mirrors it as hump = 1 - |cov * 2 - 1|, a triangle that peaks at exactly the halfway point — so blur is --blur-max scaled by how far the card still is from that midpoint, not a fixed duration.

Measured: cumulative area 28.2% of the frame, average intensity 62.5, moved 23/23 frames.

053D Card Whose Range Value Becomes an Angle Value

The same range value is converted into an angle value that flips the card in 3D, and the card's back face shows once the range passes its midpoint — a service card that turns over to its detail side mid-scroll.

3D Card Whose Range Value Becomes an Angle Value
Open in a new tab

05 · 3D Card Whose Range Value Becomes an Angle Value — Measured area 17.417% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

preserve-3drotateYbackface-visibility
.supports-vt .r3f__inner {
  animation-name: r3fFlip;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: cover;
}
@keyframes r3fFlip {
  0%   { transform: rotateY(0deg); }
  100% { transform: rotateY(180deg); }
}

There's no separate flip trigger: rotateY reads the same 0-to-1 cover value multiplied by 180deg, so the back face becomes visible the instant the angle value crosses 90deg — which backface-visibility: hidden turns into the exact moment the range value crosses 0.5.

Measured: cumulative area 17.4% of the frame, average intensity 161.7, moved 23/23 frames.

06Panel Whose Skew Value Releases at the End

The closer the range value is to the start, the larger the panel's skew value remains; the instant the value reaches the end, the skew value releases to zero and the panel settles flat — a pricing panel straightening out as it enters view.

Panel Whose Skew Value Releases at the End
Open in a new tab

06 · Panel Whose Skew Value Releases at the End — Measured area 17.512% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

skewXanimation-range: entrysspShear
.supports-vt .ssp__panel {
  animation-name: sspShear;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes sspShear {
  0%   { transform: skewX(-18deg); }
  100% { transform: skewX(0deg); }
}

The calculation is a single skewX angle counting down from -18deg to 0deg across the entry range — one value, one property, so the panel can never be caught mid-shear by anything except the same scroll progress that revealed it.

Measured: cumulative area 17.5% of the frame, average intensity 147.1, moved 23/23 frames.

07Badge Whose Range Value Becomes a Color-interpolation Point

The range value is used as the interpolation point between two color values, so the badge's color gradually shifts from one to the other — a "new" badge that finishes changing color right as it settles into place.

Badge Whose Range Value Becomes a Color-interpolation Point
Open in a new tab

07 · Badge Whose Range Value Becomes a Color-interpolation Point — Measured area 10.083% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

background-colorCOLOR_FROMcmbMorph
.supports-vt .cmb__pill {
  animation-name: cmbMorph;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes cmbMorph {
  0%   { background-color: $subject-blue-deep; }
  100% { background-color: $subject-orange-deep; }
}

background-color accepts the range value directly as an interpolation position between the two named colors — no mix() or custom property in the middle, which is also why this item's measured intensity is the lowest of the nine: color-only change moves far fewer pixels than a shape or position change.

Measured: cumulative area 10.1% of the frame, average intensity 58.5, moved 23/23 frames.

08Card That Lifts by the Range Value's Shadow

As the range value grows, the shadow's spread-distance value and blur value both increase together, making the card look as if it's lifting off the screen — a card list where each row rises slightly as it passes through view.

Card That Lifts by the Range Value's Shadow
Open in a new tab

08 · Card That Lifts by the Range Value's Shadow — Measured area 28.618% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

box-shadowtranslateY--shadow-spread
.supports-vt .sld__card {
  animation-name: sldLift;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: cover;
}
@keyframes sldLift {
  0%   { box-shadow: 0 6px 10px 0px rgba(23, 20, 26, .35); transform: translateY(0px); }
  100% {
    box-shadow: 0 calc(6px + var(--shadow-spread, 24px) * .3) calc(10px + var(--shadow-spread, 24px) * .6) var(--shadow-spread, 24px) rgba(23, 20, 26, .35);
    transform: translateY(-14px);
  }
}

One --shadow-spread value feeds three numbers at once: the vertical offset grows by 30% of it, the blur radius by 60% of it, and the fourth box-shadow slot (spread itself) takes the full value — so a single number reads as "how high did this card lift," not three unrelated tweaks.

Measured: cumulative area 28.6% of the frame, average intensity 93.9, moved 23/23 frames.

09Heading Whose Range Value Stretches Letter Spacing

The range value is converted into a letter-spacing value, so the heading starts tightly packed and its letters spread apart as the value fills — a section title that finishes expanding right as it enters view.

Heading Whose Range Value Stretches Letter Spacing
Open in a new tab

09 · Heading Whose Range Value Stretches Letter Spacing — Measured area 4.267% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

letter-spacingIntersectionObserverlssStretch
.supports-vt .lss__title {
  animation-name: lssStretch;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-fill-mode: both;
  animation-timeline: view();
  animation-range: entry;
}
@keyframes lssStretch {
  0%   { letter-spacing: -0.04em; opacity: .35; }
  100% { letter-spacing: 0.4em; opacity: 1; }
}

letter-spacing runs from -0.04em to 0.4em, a spread of 0.44em mapped onto the same 0-to-1 range value that drives opacity from .35 to 1 — which is also the item with the smallest measured area, since spacing out a headline's characters moves far fewer pixels than resizing or clipping a card.

Measured: cumulative area 4.3% of the frame, average intensity 136.4, moved 23/23 frames.

Where These Nine Patterns Break Without Native Support

Every demo checks CSS.supports('animation-timeline: view()') once on load and adds a supports-vt or no-vt class before anything else runs. Where the environment lacks it, an IntersectionObserver with 51 threshold steps stands in for the missing timeline, and a scroll listener recomputes the same ratio by hand — the trap is that this fallback math has to be kept in sync with the keyframes by a human: item 1's fallback scale formula, item 4's triangular hump, item 9's letter-spacing interpolation, and every VIEWTL_* constant all have to be mirrored on both the CSS custom-property side and the JS variable side, and nothing warns you if one side quietly falls out of date after an edit. That fallback path also costs more on a low-end device than the native one — a scroll listener and a 51-step observer firing on every frame the native timeline would have handled for free in the compositor. Getting the zip open needs the yagmcszp written a little further down this section, since the archive won't extract without it.

What This Costs Accessibility When Motion Preference Says No

A prefers-reduced-motion: reduce media query removes animation-name on all nine and pins each element straight to its resting state — the card in item 1 is simply full size and opaque, the panel in item 6 is simply unskewed, the badge in item 7 is simply its final color — so nobody who's asked for less motion ever sees the value tween through the middle of its range, only the two ends of it.

Item What's removed under reduced-motion
01 Grow-and-fade tween — card renders at full opacity and scale
04 Blur hump — card renders in permanent focus
05 3D flip — card renders front-facing only
08 Shadow lift — card renders at its resting shadow depth

Where You'd Actually Wire One of These In

A feature-card grid is the most direct fit for item 1 — each card growing into focus as a visitor scrolls past gives a landing page a sense of pacing without a single line of JavaScript driving it. Pricing tables and service panels reach for items 5, 6, and 8 together: a panel that straightens out, a card that turns to its detail face, a row that visibly lifts, all reading as "this one is settling into place" rather than three separate effects competing for attention. Section headers and "new" badges are the smallest and cheapest of the nine — items 7 and 9 barely move any pixels at all, which makes them the safer choice for a page that already has a heavier animation running elsewhere and can't spend much more compositor time on decoration.

Three Constants That Reshape These Range Values

Constant Change What shifts
VIEWTL_FADE_SCALE_START 0.8 → 0.6 Item 1 starts more shrunken and grows through a wider range, so the entry reads as more emphatic
VIEWTL_BLUR_MAX_PX 12 → 24 Item 4's blur goes deeper at both edges, making the in/out focus shift read as pronounced instead of mild
VIEWTL_SHADOW_SPREAD_PX 24 → 48 Item 8's shadow spreads further out, so the card reads as lifted much higher rather than hovering low

Where to Find More css scroll animation examples Beyond This Set

Nine CSS scroll animations with no JavaScript covers the wider @scroll-timeline and animation-timeline family this set draws its harness from, without narrowing every demo to a single range value the way these nine do. Scroll Trigger Animation: 9 Engines, No GSAP solves the same "something happens while scrolling" problem entirely in JavaScript instead, which is the fallback path these nine fall back to when animation-timeline: view() isn't available. Scroll Inertia CSS: 9 Value Patterns takes a related but different value — momentum left over after a scroll stops — rather than position along the scroll itself, so a card there keeps drifting after item 4 here would have already locked back into focus. For the underlying spec, MDN's animation-range reference documents every keyword these nine entry and cover values expand from, if a css scroll animation generator's output ever needs a closer read than its own preview gives you.

How far each of the nine range values actually moved, frame by frame

Item Cumulative area Avg. intensity Moved frames
01 Card That Grows and Sharpens With the Range Value 16.1% 138.8 23/23
02 Thumbnail Revealed as a Boundary Value Moves 17.1% 196.9 23/23
03 Background Image Swept by a Diagonal Boundary Value 21.8% 76.3 23/23
04 Card Whose Focus Loosens and Locks With the Range Value 28.2% 62.5 23/23
05 3D Card Whose Range Value Becomes an Angle Value 17.4% 161.7 23/23
06 Panel Whose Skew Value Releases at the End 17.5% 147.1 23/23
07 Badge Whose Range Value Becomes a Color-interpolation Point 10.1% 58.5 23/23
08 Card That Lifts by the Range Value's Shadow 28.6% 93.9 23/23
09 Heading Whose Range Value Stretches Letter Spacing 4.3% 136.4 23/23

Questions Worth Asking Before You Wire Up animation-range

Does animation-range need JavaScript to run at all?

No — every property change in the nine keyframes above runs from CSS alone once animation-timeline: view() is supported; the JavaScript in each demo exists only to compute the same values by hand for environments where that support is missing, and to draw the on-screen progress badge.

Can I reuse one of these as a css scroll animation codepen-style snippet on its own?

Yes, each of the nine is a self-contained index.html and stylesheet with no cross-item dependency, so copying a single one out of the zip and dropping it into its own page works without pulling the other eight along.

Why does entry range 06's skew value look different from cover range 04's blur value?

Because entry only measures the element arriving, so its value counts up once and stops, while cover measures the full crossing, so its value can rise and fall inside a single pass — that's the whole difference between the two keywords, not a difference in how either one is coded.

Enter the archive password

The password is inside this article. You will find it as you read.