|
| 1 | +--- |
| 2 | +title: "Scroll-Driven Animations — Keyframes Triggered by Scroll, No JS" |
| 3 | +description: "animation-timeline lets CSS animations advance based on scroll position. Reading-progress bars, parallax, scrubbed reveals — all without IntersectionObserver." |
| 4 | +date: 2026-05-10 |
| 5 | +slug: scroll-driven-animations-css |
| 6 | +tags: [css, animation, scroll] |
| 7 | +--- |
| 8 | + |
| 9 | +Animations bound to scroll position used to mean GreenSock + ScrollMagic, or hand-rolled `IntersectionObserver` + RAF loops. CSS now ties any keyframe animation to scroll progress — declaratively, with no JavaScript. |
| 10 | + |
| 11 | +## Reading progress bar in 8 lines |
| 12 | + |
| 13 | +```css |
| 14 | +@keyframes grow { |
| 15 | + from { width: 0; } |
| 16 | + to { width: 100%; } |
| 17 | +} |
| 18 | + |
| 19 | +.progress-bar { |
| 20 | + position: fixed; |
| 21 | + top: 0; left: 0; |
| 22 | + height: 4px; |
| 23 | + background: var(--accent); |
| 24 | + animation: grow linear; |
| 25 | + animation-timeline: scroll(root); |
| 26 | +} |
| 27 | +``` |
| 28 | + |
| 29 | +That's it. The `animation-timeline: scroll(root)` ties the animation's progress to scrolling the root scroller (i.e. the page). Scroll 50% down → animation at 50% → bar at 50% width. |
| 30 | + |
| 31 | +No `requestAnimationFrame`, no scroll listener, no `e.preventDefault()`. The browser does the math. |
| 32 | + |
| 33 | +## Reveal on scroll with `view-timeline` |
| 34 | + |
| 35 | +```css |
| 36 | +@keyframes fade-in { |
| 37 | + from { opacity: 0; transform: translateY(20px); } |
| 38 | + to { opacity: 1; transform: translateY(0); } |
| 39 | +} |
| 40 | + |
| 41 | +.card { |
| 42 | + animation: fade-in linear; |
| 43 | + animation-timeline: view(); |
| 44 | + animation-range: entry 0% cover 30%; |
| 45 | +} |
| 46 | +``` |
| 47 | + |
| 48 | +`view()` ties the animation to **the element's own viewport progress**. `animation-range` says "play from when the element starts entering to when it's covered 30%." So as the card scrolls into view, it fades in over the first 30% of its travel. Scroll back up → animation reverses. |
| 49 | + |
| 50 | +This is the IntersectionObserver-fade-in pattern, in 4 lines of CSS, with butter-smooth performance (browser-paint-thread, not main-thread JS). |
| 51 | + |
| 52 | +## Animation ranges in plain English |
| 53 | + |
| 54 | +| Range keyword | Means | |
| 55 | +|---|---| |
| 56 | +| `cover 0%` | the element first touches the viewport | |
| 57 | +| `entry 0%` | element starts entering the viewport | |
| 58 | +| `entry 100%` | element is fully inside the viewport | |
| 59 | +| `contain 0%` | element is fully inside (same as entry 100%) | |
| 60 | +| `contain 100%` | element starts to leave | |
| 61 | +| `exit 0%` | element starts leaving | |
| 62 | +| `exit 100%` | element is fully out of view | |
| 63 | +| `cover 100%` | element no longer touches the viewport | |
| 64 | + |
| 65 | +You can mix: `animation-range: entry 0% exit 100%` runs the animation from "first appears" to "fully gone" — full-trip parallax. |
| 66 | + |
| 67 | +## Horizontal scroller that animates as it scrolls |
| 68 | + |
| 69 | +```css |
| 70 | +.gallery { |
| 71 | + display: flex; |
| 72 | + overflow-x: scroll; |
| 73 | + scroll-snap-type: x mandatory; |
| 74 | +} |
| 75 | + |
| 76 | +.gallery img { |
| 77 | + scroll-snap-align: center; |
| 78 | + animation: zoom-in linear; |
| 79 | + animation-timeline: view(inline); |
| 80 | + animation-range: contain 0% contain 100%; |
| 81 | +} |
| 82 | + |
| 83 | +@keyframes zoom-in { |
| 84 | + 0%, 100% { transform: scale(0.85); opacity: 0.6; } |
| 85 | + 50% { transform: scale(1); opacity: 1; } |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +`view(inline)` watches the inline (horizontal) axis. Each image scales up at center, scales down at edges. Pure scroll-driven, no JS sync. |
| 90 | + |
| 91 | +## Multiple animations sharing one timeline |
| 92 | + |
| 93 | +```css |
| 94 | +.section { |
| 95 | + scroll-timeline: --section-trip block; |
| 96 | +} |
| 97 | + |
| 98 | +.section h2, |
| 99 | +.section .icon, |
| 100 | +.section p { |
| 101 | + animation-timeline: --section-trip; |
| 102 | + animation-range: entry 20% entry 80%; |
| 103 | +} |
| 104 | + |
| 105 | +.section h2 { animation: fade-in; animation-delay: 0%; } |
| 106 | +.section .icon { animation: fade-in; animation-delay: 10%; } |
| 107 | +.section p { animation: fade-in; animation-delay: 20%; } |
| 108 | +``` |
| 109 | + |
| 110 | +`scroll-timeline` on the section names a timeline. Children attach to it via `animation-timeline: --section-trip`. Stagger via `animation-delay` (interpreted as % of the timeline). Choreographed reveals without orchestration JavaScript. |
| 111 | + |
| 112 | +## Performance notes |
| 113 | + |
| 114 | +- Scroll-driven animations run on the **compositor** thread. Doesn't matter how heavy your JS main thread is — these animations stay smooth. |
| 115 | +- `animation-timeline: scroll()` is essentially free. The browser already tracks scroll position; you're just listening. |
| 116 | +- `view()` is slightly more work because the browser has to track each element's intersection — but still cheaper than `IntersectionObserver` callbacks. |
| 117 | + |
| 118 | +## Browser support (2026-05) |
| 119 | + |
| 120 | +[Caniuse](https://caniuse.com/css-scroll-driven-animations): |
| 121 | + |
| 122 | +- Chrome / Edge 115 (July 2023) |
| 123 | +- Safari: in flight, behind a flag |
| 124 | +- Firefox: not yet shipped |
| 125 | + |
| 126 | +For unsupported browsers, the animation falls back to its initial state (or you can set sensible static styles via `@supports not (animation-timeline: view())`). No breakage, just no scroll-trigger. |
| 127 | + |
| 128 | +```css |
| 129 | +@supports not (animation-timeline: view()) { |
| 130 | + .card { opacity: 1; transform: none; } |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +## When NOT to use it |
| 135 | + |
| 136 | +- **Animations that need to react to non-scroll events** — clicks, hover, network state. Stay with `transition` or JS. |
| 137 | +- **Triggering side-effects** (analytics events, lazy-load) on scroll. The animation doesn't tell JS anything happened. Keep `IntersectionObserver` for that side of the work. |
| 138 | +- **Scroll-jacking effects** (snap to next section). Different concept; use `scroll-snap-type` for that. |
| 139 | + |
| 140 | +## What this replaces |
| 141 | + |
| 142 | +- `IntersectionObserver` setups for fade-in-on-scroll → scroll-driven CSS |
| 143 | +- ScrollTrigger / parallax libraries for simple effects → scroll-driven CSS |
| 144 | +- Hand-rolled scroll listeners for reading-progress bars → 8 lines of CSS |
| 145 | + |
| 146 | +I rewrote a marketing page's parallax + reveal effects last week. -47 KB JavaScript, +12 lines of CSS, smoother on mobile because everything moved off the main thread. |
| 147 | + |
| 148 | +--- |
| 149 | + |
| 150 | +Animations live in the [`transitions-animations`](/transitions-animations/0/) module on [Code Crispies](/) — interactive practice with live preview. |
0 commit comments