🎬 CSS scroll-driven animations

Scroll Cinema

The scrollbar becomes the animation timeline. Progress bars, parallax, staged reveals — all running off-main-thread, no IntersectionObserver, no scroll listeners, no jank.

Use it for: Storytelling longform · product pages · annual reports

Resistance

Every mind has inertia. The first force is naming the thing that holds you still.

Momentum

Mass times velocity. Small daily pushes compound into unstoppable motion.

Flow

Equal and opposite — the world pushes back exactly as hard as you push it.

Every animation you just saw was pure CSS.
No JavaScript ran. The scrollbar was the timeline.

The complete source

This is the exact file rendering the demo above — a single self-contained React client component with its styles inline. Copy or download it, drop it into any React / Next.js project, and it runs. No extra dependencies.

scrollstory.jsx
1"use client";
2
3/* ============================================================
4 SCROLL CINEMA — 100% CSS scroll-driven animations.
5 Zero JavaScript drives any motion here: every effect binds to
6 `animation-timeline: view()` / `scroll()` (Baseline-ish 2026:
7 Chrome, Edge, Safari 26; Firefox behind a flag — wrapped in
8 @supports so it degrades to a static page gracefully).
9 ============================================================ */
10
11const SCENES = [
12 {
13 title: "Resistance",
14 text: "Every mind has inertia. The first force is naming the thing that holds you still.",
15 hue: "#7c3aed",
16 },
17 {
18 title: "Momentum",
19 text: "Mass times velocity. Small daily pushes compound into unstoppable motion.",
20 hue: "#06b6d4",
21 },
22 {
23 title: "Flow",
24 text: "Equal and opposite — the world pushes back exactly as hard as you push it.",
25 hue: "#ffa500",
26 },
27];
28
29export default function ScrollStory() {
30 return (
31 <div className="story-wrap">
32 <style>{`
33 .story-wrap { position: relative; }
34
35 /* progress bar bound to THIS scroller, not the page */
36 .story-scroller {
37 height: 70svh; overflow-y: auto; border-radius: 24px;
38 border: 1px solid rgba(255,255,255,.15);
39 background: #0a0a12;
40 scroll-timeline: --story block; /* named scroll timeline */
41 }
42 .story-bar {
43 position: sticky; top: 0; z-index: 5; height: 4px; width: 100%;
44 transform-origin: 0 50%; transform: scaleX(0);
45 background: linear-gradient(90deg,#7c3aed,#06b6d4,#ffa500);
46 }
47 @supports (animation-timeline: scroll()) {
48 .story-bar { animation: story-grow linear both; animation-timeline: --story; }
49 }
50 @keyframes story-grow { to { transform: scaleX(1); } }
51
52 .story-scene {
53 min-height: 95svh; display: grid; place-items: center;
54 padding: 4rem 2rem; position: relative; overflow: clip;
55 }
56 .story-card { max-width: 56ch; text-align: center; position: relative; z-index: 2; }
57 .story-title { font-size: 4.8rem; font-weight: 900; letter-spacing: -.02em; }
58 .story-text { font-size: 1.8rem; line-height: 1.7; color: rgba(255,255,255,.78); margin-top: 1.6rem; }
59
60 /* each scene's halo scales/rotates as it crosses the viewport */
61 .story-halo {
62 position: absolute; inset: 10%; border-radius: 50%;
63 filter: blur(60px); opacity: .5; z-index: 1;
64 }
65 @supports (animation-timeline: view()) {
66 .story-card {
67 animation: scene-in linear both;
68 animation-timeline: view(block);
69 animation-range: entry 10% entry 60%;
70 }
71 .story-halo {
72 animation: halo-spin linear both;
73 animation-timeline: view(block);
74 animation-range: cover 0% cover 100%;
75 }
76 .story-num {
77 animation: num-slide linear both;
78 animation-timeline: view(block);
79 animation-range: cover 20% cover 80%;
80 }
81 }
82 @keyframes scene-in {
83 from { opacity: 0; transform: translateY(80px) scale(.92); }
84 to { opacity: 1; transform: none; }
85 }
86 @keyframes halo-spin {
87 from { transform: rotate(0deg) scale(.6); }
88 to { transform: rotate(180deg) scale(1.15); }
89 }
90 @keyframes num-slide {
91 from { transform: translateX(-30%); opacity: .04; }
92 to { transform: translateX(10%); opacity: .12; }
93 }
94 .story-num {
95 position: absolute; left: 0; top: 50%; translate: 0 -50%;
96 font-size: 28rem; font-weight: 900; opacity: .08; z-index: 0;
97 pointer-events: none; line-height: 1;
98 }
99 .story-end {
100 min-height: 50svh; display: grid; place-items: center;
101 font-size: 2rem; color: rgba(255,255,255,.6); text-align: center;
102 }
103 `}</style>
104
105 <div className="story-scroller" tabIndex={0} aria-label="Scroll story">
106 <div className="story-bar" aria-hidden="true" />
107 {SCENES.map((s, i) => (
108 <section className="story-scene" key={s.title}>
109 <div
110 className="story-halo"
111 style={{ background: `radial-gradient(circle, ${s.hue}, transparent 70%)` }}
112 aria-hidden="true"
113 />
114 <div className="story-num" aria-hidden="true">
115 {i + 1}
116 </div>
117 <div className="story-card">
118 <h3 className="story-title" style={{ color: s.hue }}>
119 {s.title}
120 </h3>
121 <p className="story-text">{s.text}</p>
122 </div>
123 </section>
124 ))}
125 <div className="story-end">
126 <p>
127 Every animation you just saw was pure CSS.
128 <br />
129 No JavaScript ran. The scrollbar was the timeline.
130 </p>
131 </div>
132 </div>
133 </div>
134 );
135}
136