Description
This project demonstrates a **fully scroll-driven multi-stage image comparator** powered primarily by **modern CSS**, with JavaScript used only for measurement, smoothing, and navigation logic.
Unlike traditional before/after sliders, this system:
- Uses scroll position as the timeline
- Automatically adapts to any number of image layers
- Calculates animation ranges using
sibling-index()andsibling-count() - Requires no manual z-index or timing configuration
The result is a scalable, declarative, and future-forward comparator system ideal for editorial storytelling, case studies, and immersive visual narratives.
HTML
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Document</title>
7 <link rel="stylesheet" href="style.css">
8</head>
9<body>
10 <main>
11 <article>
12 <section class="intro">
13 <h1>CSS Scroll-Driven Multi-Stage Comparator</h1>
14 <p>
15 A multi-stage image comparator driven by scroll position using CSS
16 <code>scroll-timeline</code> and <code>animation-range</code>. The system uses
17 <code>sibling-index()</code> and <code>sibling-count()</code> to calculate layer
18 timing and stacking order automatically - add or remove image layers and the CSS
19 recalculates z-index, clip-path windows, and divider synchronization without
20 manual class numbering.
21 </p>
22 <p>
23 The percentage counter is calculated in CSS using <code>@property</code> with
24 scroll-driven animations. JavaScript handles section offset measurement,
25 percentage display formatting, velocity-based wheel and touchpad smoothing,
26 and interactive stage navigation with click-to-jump indicators.
27 </p>
28 <p><small>Note: Images generated with the "thing" (AI) and curated by @me as part of <a href="https://www.linkedin.com/posts/luis-martinez-lr_creativeprocess-collage-generativeart-activity-7341093878554324992-NEW9/" target="_blank" rel="noopener">The Beauty of Scrambled Prompts</a> and <a href="https://www.linkedin.com/posts/luis-martinez-lr_no-fixed-recipe-just-collisions-accidents-activity-7364286262096142336-9Exj/" target="_blank" rel="noopener">Tiny Worlds Accidents</a>.</small></p>
29 </section>
30 <section>
31 <p class="scroll-indicator">Scroll down ↓</p>
32 </section>
33 <section class="scroll-section">
34 <div class="comparator-container">
35 <div class="comparator-wrapper">
36 <div class="comparator">
37 <div class="comparison-percentage"></div>
38 <div class="image-layers">
39 <div class="image-layer">
40 <picture>
41 <source media="(max-width: 48em)" srcset="https://lessrain.com/dev/images-2025/scroll/820/lr-scroll-img-01-820x984.webp">
42 <img src="https://lessrain.com/dev/images-2025/scroll/1024/lr-scroll-img-01-1024x683.webp" decoding="async" fetchpriority="high" alt="Stage 1">
43 </picture>
44 <div class="comparator-overlay">
45 <span class="label">Stage 1</span>
46 <div class="image-text">
47 <h2>Beach Origin</h2>
48 <h3>— Turquoise Shoreline</h3>
49 </div>
50 </div>
51 </div>
52 <div class="image-layer">
53 <picture>
54 <source media="(max-width: 48em)" srcset="https://lessrain.com/dev/images-2025/scroll/820/lr-scroll-img-02-820x984.webp">
55 <img src="https://lessrain.com/dev/images-2025/scroll/1024/lr-scroll-img-02-1024x683.webp" decoding="async" fetchpriority="high" alt="Stage 2">
56 </picture>
57 <div class="comparator-overlay">
58 <span class="label">Stage 2</span>
59 <div class="image-text">
60 <h2>Beach Evolution</h2>
61 <h3>— Surreal Egg-Shaped</h3>
62 </div>
63 </div>
64 </div>
65 <div class="image-layer">
66 <picture>
67 <source media="(max-width: 48em)" srcset="https://lessrain.com/dev/images-2025/scroll/820/lr-scroll-img-01-820x984.webp">
68 <img src="https://lessrain.com/dev/images-2025/scroll/1024/lr-scroll-img-01-1024x683.webp" decoding="async" fetchpriority="high" alt="Stage 3">
69 </picture>
70 <div class="comparator-overlay">
71 <span class="label">Stage 3</span>
72 <div class="image-text">
73 <h2>Beach Final</h2>
74 <h3>— Full Circle</h3>
75 </div>
76 </div>
77 </div>
78 </div>
79 <div class="divider-lines">
80 <div class="divider-line"></div>
81 <div class="divider-line"></div>
82 </div>
83 </div>
84 </div>
85 </div>
86 </section>
87 <section>
88 <p class="scroll-indicator">Scroll up ↑</p>
89 </section>
90 <section class="spacer"></section>
91 </article>
92</main>
93
94<Script src="script.js" ></Script>
95</body>
96</html>
🎨 CSS Code
1@property --scroll-progress { 2 inherits: true; 3 initial-value: 0; 4 syntax: "<number>"; 5} 6 7@property --layer-index { 8 syntax: "<integer>"; 9 inherits: true; 10 initial-value: 1; 11} 12 13@property --layer-count { 14 syntax: "<integer>"; 15 inherits: true; 16 initial-value: 1; 17} 18 19@property --divider-index { 20 syntax: "<integer>"; 21 inherits: true; 22 initial-value: 1; 23} 24 25@property --divider-count { 26 syntax: "<integer>"; 27 inherits: true; 28 initial-value: 1; 29} 30 31@layer reset, 32base, 33typography, 34layout, 35comparator, 36navigation, 37links; 38 39@layer reset { 40 *, 41 *::after, 42 *::before { 43 box-sizing: border-box; 44 margin: 0; 45 padding: 0; 46 } 47 48 html { 49 color-scheme: light dark; 50 -webkit-text-size-adjust: 100%; 51 -moz-text-size-adjust: 100%; 52 text-size-adjust: 100%; 53 overflow-y: scroll; 54 } 55} 56 57@layer base { 58 :root { 59 --color-light: #b9ae8d; 60 --color-dark: #1f1408; 61 --color-light-lighter: color-mix(in oklch, var(--color-light), #fff 10%); 62 --color-light-darker: color-mix(in oklch, var(--color-light), #000 10%); 63 --color-dark-lighter: color-mix(in oklch, var(--color-dark), #fff 10%); 64 --color-dark-darker: color-mix(in oklch, var(--color-dark), #000 10%); 65 --color-bg: var(--color-light); 66 --color-bg-alt: var(--color-light-darker); 67 --color-text: var(--color-dark); 68 --color-text-muted: var(--color-dark-lighter); 69 --color-accent: color-mix(in oklch, var(--color-dark), #fff 30%); 70 71 --support-message-bg: var(--color-dark-darker); 72 --support-message-text: var(--color-light-lighter); 73 74 --space-md: 1rem; 75 --space-lg: 1.5rem; 76 --space-xl: 2rem; 77 --space-xxl: 3rem; 78 --line-tight: 1.2; 79 --line-base: 1.5; 80 --line-loose: 1.75; 81 --font-sans: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 82 Noto Sans, sans-serif; 83 --font-mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Monaco, 84 Consolas, monospace; 85 --ts-xxs: clamp(0.75rem, -4cqw + 0.35rem, 0.9rem); 86 --ts-xs: clamp(0.81rem, -3cqw + 0.35rem, 1.035rem); 87 --ts-sm: clamp(0.9113rem, -1.5cqw + 0.35rem, 1.1644rem); 88 --ts-base: clamp(1.0125rem, 0cqw + 0.35rem, 1.2938rem); 89 --ts-md: clamp(1.1391rem, 1.5cqw + 0.35rem, 1.4555rem); 90 --ts-lg: clamp(1.2656rem, 3cqw + 0.35rem, 1.6172rem); 91 --ts-xl: clamp(1.582rem, 6cqw + 0.35rem, 2.0215rem); 92 --ts-xxl: clamp(1.9775rem, 9cqw + 0.35rem, 2.5269rem); 93 --ts-xxxl: clamp(2.4719rem, 12cqw + 0.35rem, 3.1586rem); 94 95 --comparator-duration: 400vh; 96 --comparator-offset: 35vh; 97 --comparator-max-width: 56.25rem; 98 --comparator-max-height: 100vh; 99 --comparator-aspect-ratio: 16/11; 100 101 accent-color: var(--color-accent); 102 } 103 104 @media (max-width: 48em) { 105 :root { 106 --comparator-aspect-ratio: 4/5; 107 } 108 } 109 110 @media (prefers-color-scheme: dark) { 111 :root { 112 --color-bg: var(--color-dark); 113 --color-bg-alt: var(--color-dark-lighter); 114 --color-text: var(--color-light); 115 --color-text-muted: var(--color-light-darker); 116 --color-accent: color-mix(in oklch, var(--color-dark), #fff 75%); 117 --support-message-bg: var(--color-light-darker); 118 --support-message-text: var(--color-dark-lighter); 119 } 120 } 121 122 ::selection { 123 background: var(--color-accent); 124 color: var(--color-bg); 125 } 126 127 body { 128 background: linear-gradient( 129 to bottom, 130 var(--color-bg), 131 color-mix(in oklch, var(--color-bg), var(--color-text) 20%) 132 ); 133 color: var(--color-text); 134 font-family: var(--font-sans); 135 font-size: var(--ts-base); 136 -webkit-font-smoothing: antialiased; 137 -moz-osx-font-smoothing: grayscale; 138 line-height: var(--line-base); 139 min-block-size: 100vh; 140 padding-block-start: var(--space-xl); 141 text-rendering: optimizeLegibility; 142 } 143} 144 145@layer typography { 146 :where(section, article) > * + * { 147 margin-block-start: var(--space-lg); 148 } 149 150 :is(h1, h2, h3, h4, h5, h6) { 151 font-weight: 600; 152 letter-spacing: -0.02em; 153 line-height: var(--line-tight); 154 } 155 156 h1 { 157 font-size: var(--ts-xxxl); 158 letter-spacing: -0.03em; 159 margin-block-end: var(--space-lg); 160 } 161 162 p { 163 hyphens: auto; 164 line-height: var(--line-loose); 165 text-wrap: pretty; 166 word-wrap: break-word; 167 } 168 169 code { 170 background: var(--color-bg-alt); 171 border-radius: 0.25em; 172 font-family: var(--font-mono); 173 font-size: 0.9em; 174 padding: 0.125em 0.25em; 175 } 176 177 a { 178 color: var(--color-accent); 179 text-decoration: underline; 180 text-underline-offset: 0.2em; 181 } 182 183 small { 184 font-size: var(--ts-sm); 185 } 186} 187 188@layer layout { 189 @supports not ( 190 (animation-timeline: scroll()) and (z-index: sibling-count()) 191 ) { 192 .intro::before { 193 background: var(--support-message-bg); 194 border-radius: 0.5rem; 195 color: var(--support-message-text); 196 content: "Your browser doesn't support the advanced CSS features required for this interactive layout. Please use the latest Chrome or Edge."; 197 display: block; 198 font-size: var(--ts-base); 199 font-weight: 600; 200 margin-block: var(--space-xl); 201 padding: var(--space-md); 202 position: relative; 203 } 204 } 205 206 .intro { 207 display: block; 208 inline-size: calc(100% - var(--space-xl)); 209 margin: 0 auto; 210 max-inline-size: 56.25rem; 211 padding-block-end: var(--space-xxl); 212 position: relative; 213 } 214 215 .scroll-section { 216 block-size: calc(var(--comparator-duration) + 100vh); 217 position: relative; 218 } 219 220 .spacer { 221 block-size: 50vh; 222 } 223 224 .scroll-indicator { 225 font-size: var(--ts-xl); 226 max-inline-size: 100%; 227 text-align: center; 228 } 229} 230 231@layer comparator { 232 .comparator-container { 233 align-items: center; 234 block-size: 100vh; 235 display: flex; 236 inset-block-start: 0; 237 justify-content: center; 238 overflow: hidden; 239 position: sticky; 240 } 241 242 .comparator-wrapper { 243 animation: comparator-3d-flip linear both; 244 animation-range: calc(var(--comparator-offset) - 50vh) 245 calc(var(--comparator-offset) + var(--comparator-duration) + 50vh); 246 animation-timeline: scroll(root); 247 aspect-ratio: var(--comparator-aspect-ratio); 248 border-radius: 0.5rem; 249 inline-size: 100%; 250 margin-inline: var(--space-md); 251 max-block-size: var(--comparator-max-height); 252 max-inline-size: var(--comparator-max-width); 253 overflow: hidden; 254 position: relative; 255 } 256 257 .comparator-wrapper.flip-reverse { 258 animation-name: comparator-3d-flip-reverse; 259 } 260 261 .comparator { 262 animation: progress-calc linear both; 263 animation-range: var(--comparator-offset) 264 calc(var(--comparator-offset) + var(--comparator-duration)); 265 animation-timeline: scroll(root); 266 block-size: 100%; 267 display: grid; 268 inline-size: 100%; 269 position: relative; 270 } 271 272 .image-layers, 273 .divider-lines { 274 grid-area: 1 / -1; 275 display: grid; 276 position: relative; 277 } 278 279 .image-layer { 280 display: grid; 281 grid-area: 1 / -1; 282 position: relative; 283 z-index: calc(sibling-count() - sibling-index() + 1); 284 } 285 286 .image-layer:not(:last-child) { 287 --layer-index: sibling-index(); 288 --layer-count: sibling-count(); 289 --layer-start: calc((var(--layer-index) - 1) / (var(--layer-count) - 1)); 290 --layer-end: calc(var(--layer-index) / (var(--layer-count) - 1)); 291 292 animation: clip-reveal linear both; 293 animation-timeline: scroll(root); 294 animation-range: calc( 295 var(--comparator-offset) + 296 (var(--comparator-duration) * var(--layer-start)) 297 ) 298 calc( 299 var(--comparator-offset) + 300 (var(--comparator-duration) * var(--layer-end)) 301 ); 302 } 303 304 picture { 305 grid-area: 1 / -1; 306 max-block-size: var(--comparator-max-height); 307 inline-size: 100%; 308 block-size: 100%; 309 display: block; 310 } 311 312 .image-layer img { 313 block-size: 100%; 314 display: block; 315 inline-size: 100%; 316 object-fit: cover; 317 object-position: center; 318 aspect-ratio: var(--comparator-aspect-ratio); 319 background: color-mix(in oklch, var(--color-bg), var(--color-text) 5%); 320 } 321 322 .divider-line { 323 --divider-index: sibling-index(); 324 --divider-count: sibling-count(); 325 --layer-start: calc((var(--divider-index) - 1) / var(--divider-count)); 326 --layer-end: calc(var(--divider-index) / var(--divider-count)); 327 328 background: transparent; 329 block-size: 100%; 330 border-inline-start: thin solid var(--color-bg); 331 box-shadow: 0 0 10px 332 color-mix(in srgb, var(--color-accent), transparent 50%); 333 grid-area: 1 / -1; 334 inline-size: 1px; 335 pointer-events: none; 336 position: relative; 337 z-index: calc(20 - var(--divider-index)); 338 339 animation: divider-move linear both; 340 animation-timeline: scroll(root); 341 animation-range: calc( 342 var(--comparator-offset) + 343 (var(--comparator-duration) * var(--layer-start)) 344 ) 345 calc( 346 var(--comparator-offset) + 347 (var(--comparator-duration) * var(--layer-end)) 348 ); 349 } 350 351 .comparator-overlay { 352 block-size: 100%; 353 display: flex; 354 flex-direction: column; 355 grid-area: 1 / -1; 356 inline-size: 100%; 357 max-block-size: var(--comparator-max-height); 358 position: relative; 359 transform: translateZ(30px); 360 } 361 362 .label { 363 backdrop-filter: blur(0.375rem); 364 background: color-mix(in srgb, var(--color-dark), transparent 20%); 365 border-radius: 1rem; 366 color: var(--color-light); 367 font-size: var(--ts-xxs); 368 font-weight: 600; 369 inline-size: fit-content; 370 letter-spacing: 0.05em; 371 margin-block: var(--space-md) auto; 372 margin-inline: var(--space-md) auto; 373 padding: 0.375rem 0.75rem; 374 pointer-events: none; 375 position: relative; 376 text-transform: uppercase; 377 white-space: nowrap; 378 z-index: 11; 379 } 380 381 .image-text { 382 animation: text-reveal 0.6s ease-out both; 383 animation-range: var(--comparator-offset) 384 calc(var(--comparator-offset) + 20vh); 385 animation-timeline: scroll(root); 386 margin-block: auto var(--space-md); 387 margin-inline: var(--space-md) auto; 388 pointer-events: none; 389 position: relative; 390 white-space: nowrap; 391 z-index: 10; 392 } 393 394 .image-text h2 { 395 font-size: var(--ts-lg); 396 font-weight: 500; 397 letter-spacing: -0.03em; 398 line-height: 1.2; 399 margin: 0; 400 color: var(--color-light-lighter); 401 } 402 403 .image-text h3 { 404 font-size: var(--ts-md); 405 font-weight: 400; 406 line-height: 1.4; 407 margin: 0; 408 color: var(--color-light); 409 } 410 411 .comparison-percentage { 412 color: var(--color-light-lighter); 413 bottom: var(--space-md); 414 font-size: var(--ts-md); 415 font-variant-numeric: tabular-nums; 416 font-weight: 500; 417 line-height: 1.4; 418 pointer-events: none; 419 position: absolute; 420 right: var(--space-md); 421 z-index: 20; 422 } 423 424 @keyframes comparator-3d-flip { 425 0% { 426 opacity: 0.75; 427 transform: perspective(1200px) rotateX(10deg) rotateY(-10deg) 428 rotateZ(-3deg) scale(0.85); 429 } 430 431 15%, 432 85% { 433 opacity: 1; 434 transform: perspective(1200px) rotateX(0deg) rotateY(0deg) rotateZ(0deg) 435 scale(1); 436 } 437 438 100% { 439 opacity: 0.75; 440 transform: perspective(1200px) rotateX(-10deg) rotateY(10deg) 441 rotateZ(3deg) scale(0.85); 442 } 443 } 444 445 @keyframes comparator-3d-flip-reverse { 446 0% { 447 opacity: 0.75; 448 transform: perspective(1200px) rotateX(-10deg) rotateY(10deg) 449 rotateZ(3deg) scale(0.85); 450 } 451 452 15%, 453 85% { 454 opacity: 1; 455 transform: perspective(1200px) rotateX(0deg) rotateY(0deg) rotateZ(0deg) 456 scale(1); 457 } 458 459 100% { 460 opacity: 0.75; 461 transform: perspective(1200px) rotateX(10deg) rotateY(-10deg) 462 rotateZ(-3deg) scale(0.85); 463 } 464 } 465 466 @keyframes progress-calc { 467 from { 468 --scroll-progress: 0; 469 } 470 471 to { 472 --scroll-progress: 100; 473 } 474 } 475 476 @keyframes clip-reveal { 477 from { 478 clip-path: inset(0 0 0 0); 479 } 480 481 to { 482 clip-path: inset(0 100% 0 0); 483 } 484 } 485 486 @keyframes divider-move { 487 0% { 488 inset-inline-start: 100%; 489 opacity: 0; 490 } 491 492 2% { 493 opacity: 1; 494 } 495 496 98% { 497 opacity: 1; 498 } 499 500 100% { 501 inset-inline-start: 0%; 502 opacity: 0; 503 } 504 } 505 506 @keyframes text-reveal { 507 0% { 508 opacity: 0; 509 transform: translateY(20px); 510 } 511 512 100% { 513 opacity: 1; 514 transform: translateY(0); 515 } 516 } 517} 518 519@layer navigation { 520 .stage-nav { 521 display: flex; 522 flex-direction: column; 523 gap: 0.5rem; 524 position: absolute; 525 right: var(--space-md); 526 top: 50%; 527 transform: translateY(-50%); 528 z-index: 25; 529 pointer-events: auto; 530 } 531 532 .stage-indicator { 533 appearance: none; 534 background: color-mix(in srgb, var(--color-light), transparent 70%); 535 border: none; 536 border-radius: 0.25rem; 537 cursor: pointer; 538 height: 0.5rem; 539 padding: 0; 540 transition: all 0.2s ease; 541 width: 0.5rem; 542 } 543 544 .stage-indicator:hover { 545 background: color-mix(in srgb, var(--color-light), transparent 30%); 546 transform: scale(1.3); 547 } 548 549 .stage-indicator.active { 550 background: var(--color-light); 551 height: 1rem; 552 } 553 554 .stage-indicator:focus-visible { 555 outline: 2px solid var(--color-accent); 556 outline-offset: 2px; 557 } 558 559 @media (max-width: 48em) { 560 .stage-nav { 561 flex-direction: row; 562 right: 50%; 563 top: auto; 564 bottom: calc(var(--space-md) * 3); 565 transform: translateX(50%); 566 } 567 568 .stage-indicator.active { 569 height: 0.5rem; 570 width: 1rem; 571 } 572 } 573} 574 575@layer links { 576 .links-layer { 577 inset-block-end: 0; 578 inset-inline-end: 0; 579 pointer-events: none; 580 position: fixed; 581 z-index: 1000; 582 } 583 584 .links { 585 backdrop-filter: blur(0.375rem); 586 background: color-mix(in srgb, var(--color-dark), transparent 20%); 587 display: grid; 588 font-size: 0.75rem; 589 gap: 0; 590 grid-auto-flow: column; 591 line-height: 1.3; 592 padding-block: 0.375rem; 593 padding-inline: 0.625rem; 594 pointer-events: auto; 595 } 596 597 .links a { 598 border-inline-start: thin solid 599 color-mix(in srgb, currentColor, transparent 50%); 600 color: var(--color-light); 601 padding-inline: 0.5rem; 602 text-decoration: none; 603 transition: color 0.25s ease, opacity 0.25s ease; 604 } 605 606 .links a:first-child { 607 border: none; 608 } 609 610 .links a:hover, 611 .links a:focus-visible { 612 opacity: 0.85; 613 } 614}
Javascript Code
1(function () {
2 "use strict";
3 let velocity = 0;
4 const ease = 0.12;
5 const friction = 0.92;
6 const sections = document.querySelectorAll(".scroll-section");
7 const sectionsLen = sections.length;
8 const wrappers = [];
9 const comparatorData = [];
10 let i, s, w, c, p;
11
12 for (i = 0; i < sectionsLen; i++) {
13 s = sections[i];
14 w = s.querySelector(".comparator-wrapper");
15 if (w) wrappers.push({ section: s, wrapper: w });
16 c = s.querySelector(".comparator");
17 if (!c) continue;
18 p = c.querySelector(".comparison-percentage");
19 if (p) {
20 const layers = c.querySelectorAll(".image-layer");
21 comparatorData.push({
22 comp: c,
23 pct: p,
24 section: s,
25 layerCount: layers.length,
26 wrapper: w
27 });
28 }
29 }
30
31 const wrappersLen = wrappers.length;
32 const compLen = comparatorData.length;
33 let d, v;
34
35 function createStageIndicators() {
36 for (i = 0; i < compLen; i++) {
37 d = comparatorData[i];
38 const nav = document.createElement("div");
39 nav.className = "stage-nav";
40
41 const indicators = [];
42 for (let j = 0; j < d.layerCount; j++) {
43 const indicator = document.createElement("button");
44 indicator.className = "stage-indicator";
45 indicator.setAttribute("aria-label", `Go to stage ${j + 1}`);
46 indicator.dataset.stage = j;
47 indicator.dataset.comparatorIndex = i;
48 indicators.push(indicator);
49 nav.appendChild(indicator);
50 }
51
52 d.comp.appendChild(nav);
53 d.indicators = indicators;
54 }
55 }
56
57 function getComparatorDuration() {
58 const style = getComputedStyle(document.documentElement);
59 const duration = style.getPropertyValue("--comparator-duration").trim();
60 return (parseFloat(duration) * window.innerHeight) / 100;
61 }
62
63 let targetScrollPosition = null;
64 const scrollEase = 0.08;
65
66 function scrollToStage(comparatorIndex, stageIndex) {
67 const data = comparatorData[comparatorIndex];
68 if (!data) return;
69
70 const offset = data.section.offsetTop;
71 const duration = getComparatorDuration();
72 const stageCount = data.layerCount;
73
74 stageIndex = Math.max(0, Math.min(stageIndex, stageCount - 1));
75
76 const stageDuration = duration / (stageCount - 1);
77 targetScrollPosition = offset + stageDuration * stageIndex;
78 }
79
80 function onIndicatorClick(e) {
81 const btn = e.target.closest(".stage-indicator");
82 if (!btn) return;
83
84 const stage = parseInt(btn.dataset.stage, 10);
85 const compIndex = parseInt(btn.dataset.comparatorIndex, 10);
86
87 scrollToStage(compIndex, stage);
88 }
89
90 function updateOffsets() {
91 for (i = 0; i < wrappersLen; i++) {
92 w = wrappers[i];
93 w.wrapper.style.setProperty(
94 "--comparator-offset",
95 w.section.offsetTop + "px"
96 );
97 }
98 }
99
100 function onWheel(e) {
101 e.preventDefault();
102 targetScrollPosition = null;
103 velocity += e.deltaY;
104 }
105
106 let resizeTimeout;
107
108 function onResize() {
109 targetScrollPosition = null;
110
111 clearTimeout(resizeTimeout);
112 resizeTimeout = setTimeout(() => {
113 updateOffsets();
114 }, 150);
115 }
116
117 function onMouseDown(e) {
118 if (!e.target.closest(".comparator-wrapper")) {
119 targetScrollPosition = null;
120 }
121 }
122
123 function frame() {
124 if (targetScrollPosition !== null) {
125 const current = window.scrollY;
126 const delta = targetScrollPosition - current;
127
128 if (Math.abs(delta) > 1) {
129 window.scrollBy(0, delta * scrollEase);
130 } else {
131 targetScrollPosition = null;
132 }
133 }
134
135 velocity *= friction;
136 if (velocity > 0.2 || velocity < -0.2) {
137 window.scrollBy(0, velocity * ease);
138 }
139
140 for (i = 0; i < compLen; i++) {
141 d = comparatorData[i];
142 v =
143 parseFloat(
144 getComputedStyle(d.comp).getPropertyValue("--scroll-progress")
145 ) || 0;
146 d.pct.textContent = (Math.round(v) + "").padStart(2, "0") + "%";
147
148 const currentStage = Math.round((v / 100) * (d.layerCount - 1));
149 d.indicators.forEach((indicator, idx) => {
150 indicator.classList.toggle("active", idx === currentStage);
151 });
152 }
153 requestAnimationFrame(frame);
154 }
155
156 window.addEventListener("wheel", onWheel, { passive: false });
157 window.addEventListener("resize", onResize, { passive: true });
158 window.addEventListener("mousedown", onMouseDown, { passive: true });
159 document.addEventListener("click", onIndicatorClick);
160
161 window.addEventListener("load", () => {
162 createStageIndicators();
163 updateOffsets();
164 requestAnimationFrame(frame);
165 });
166})();
How It Works
- Scroll as the Timeline
Uses animation-timeline: scroll(root)
Scroll position directly maps to animation progress
No JavaScript-driven scroll tracking required
- Automatic Layer Calculation
sibling-index() and sibling-count() determine:
Reveal timing
Z-index stacking
Divider synchronization
Adding or removing layers requires zero CSS changes
- Clip-Based Image Reveal
Each image layer reveals itself using clip-path
The reveal window moves horizontally with scroll
Divider lines visually reinforce stage boundaries
- CSS-Driven Percentage Counter
Scroll progress is calculated via @property
JavaScript only formats and displays the value
No scroll math or DOM measurement per frame
- Stage Navigation Enhancements
JavaScript dynamically creates stage indicators
Clicking an indicator scrolls to the correct stage
Active stage updates automatically based on scroll progress
Key Features
🧠 CSS-first animation architecture
📜 Scroll-driven multi-stage storytelling
🔢 Automatic layer timing & stacking
✂️ Clip-path based image comparison
🧭 Interactive stage navigation
🧩 Scales to any number of layers
⚡ Minimal JavaScript usage
Use Cases
Case study comparisons
Editorial storytelling
Before / after product visuals
Design process breakdowns
Portfolio feature sections
