Back to Components
CSS Scroll-Driven Multi-Stage Image Comparator
Component

CSS Scroll-Driven Multi-Stage Image Comparator

CodewithLord
December 19, 2025

An advanced scroll-driven multi-stage image comparator built with modern CSS features like scroll-timeline, animation-range, sibling-index(), and @property. The system automatically synchronizes layered image reveals, divider motion, percentage counters, and stage navigation with minimal JavaScript.

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() and sibling-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


  1. Scroll as the Timeline

Uses animation-timeline: scroll(root)

Scroll position directly maps to animation progress

No JavaScript-driven scroll tracking required

  1. Automatic Layer Calculation

sibling-index() and sibling-count() determine:

Reveal timing

Z-index stacking

Divider synchronization

Adding or removing layers requires zero CSS changes

  1. 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

  1. 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

  1. 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

Love this component?

Explore more components and build amazing UIs.

View All Components