Multi-Stage Image Comparison Carousel with CSS Scroll-timeline

Category: Image , Javascript | December 23, 2025
Authorluis-lessrain
Last UpdateDecember 23, 2025
LicenseMIT
Views24 views
Multi-Stage Image Comparison Carousel with CSS Scroll-timeline

Multi-Stage Comparator is a scroll-driven image comparison carousel that reveals multiple image layers based on scroll position.

Features:

  • Automatic layer calculation: Uses CSS sibling-index() and sibling-count() to determine z-index and animation timing without manual configuration.
  • Scroll-timeline driven: Leverages native CSS scroll-timeline and animation-range for hardware-accelerated transitions.
  • Velocity-based smoothing: JavaScript handles scroll velocity calculations with configurable friction and easing values.
  • CSS-calculated percentage: Uses @property with scroll-driven animations to display progress without JavaScript calculations.
  • Interactive stage navigation: Click-to-jump indicators allow direct navigation to specific image stages.
  • Responsive image support: Implements picture elements with source media queries for mobile-optimized layouts.
  • 3D perspective effects: Optional CSS transforms create depth during scroll entry and exit animations.

See It In Action:

Use Cases:

  • Product evolution timelines: Display multiple stages of product development or design iterations in a single scroll interaction.
  • Before/after comparisons: Show transformation results for photo editing, web design mockups, or renovation projects.
  • Tutorial progressions: Demonstrate step-by-step processes where each stage builds on the previous state.
  • Portfolio presentations: Create image carousels that reveal design decisions or implementation phases.

How to use it:

1. Create a container with the class .scroll-section. Inside, nest the comparator structure. You can add as many .image-layer divs as needed. The CSS will handle the stacking order.

<!-- Main Scroll Section -->
<section class="scroll-section">
  <div class="comparator-container">
    <div class="comparator-wrapper">
      <div class="comparator">
        <!-- Percentage Counter -->
        <div class="comparison-percentage"></div>
        <!-- Image Layers Group -->
        <div class="image-layers">
          <!-- Layer 1 -->
          <div class="image-layer">
            <picture>
              <source media="(max-width: 48em)" srcset="path/to/image-1-mobile.webp">
              <img src="path/to/image-1.webp" decoding="async" alt="Stage 1">
            </picture>
            <div class="comparator-overlay">
              <span class="label">Stage 1</span>
              <div class="image-text">
                <h2>Title Here</h2>
                <h3>Subtitle Here</h3>
              </div>
            </div>
          </div>
          <!-- Layer 2 -->
          <div class="image-layer">
            <picture>
              <source media="(max-width: 48em)" srcset="path/to/image-2-mobile.webp">
              <img src="path/to/image-2.webp" decoding="async" alt="Stage 2">
            </picture>
            <div class="comparator-overlay">
              <span class="label">Stage 2</span>
              <div class="image-text">
                <h2>Title Here</h2>
                <h3>Subtitle Here</h3>
              </div>
            </div>
          </div>
          <!-- Add more layers as needed -->
        </div>
        <!-- Decorative Divider Lines -->
        <div class="divider-lines">
          <div class="divider-line"></div>
          <div class="divider-line"></div>
          <div class="divider-line"></div>
        </div>
      </div>
    </div>
  </div>
</section>

2. Define custom properties and layer styles. The CSS handles automatic layer calculations and scroll-driven animations.

@property --scroll-progress {
  inherits: true;
  initial-value: 0;
  syntax: "";
}
@property --layer-index {
  syntax: "";
  inherits: true;
  initial-value: 1;
}
@property --layer-count {
  syntax: "";
  inherits: true;
  initial-value: 1;
}
@property --divider-index {
  syntax: "";
  inherits: true;
  initial-value: 1;
}
@property --divider-count {
  syntax: "";
  inherits: true;
  initial-value: 1;
}
@layer reset,
base,
typography,
layout,
comparator,
navigation,
@layer reset {
  *,
  *::after,
  *::before {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
  }
  html {
    color-scheme: light dark;
    -webkit-text-size-adjust: 100%;
    -moz-text-size-adjust: 100%;
    text-size-adjust: 100%;
    overflow-y: scroll;
  }
}
@layer base {
  :root {
    --color-light: #fafafa;
    --color-dark: #1f1408;
    --color-light-lighter: color-mix(in oklch, var(--color-light), #fff 10%);
    --color-light-darker: color-mix(in oklch, var(--color-light), #000 10%);
    --color-dark-lighter: color-mix(in oklch, var(--color-dark), #fff 10%);
    --color-dark-darker: color-mix(in oklch, var(--color-dark), #000 10%);
    --color-bg: var(--color-light);
    --color-bg-alt: var(--color-light-darker);
    --color-text: var(--color-dark);
    --color-text-muted: var(--color-dark-lighter);
    --color-accent: color-mix(in oklch, var(--color-dark), #fff 60%);
    --support-message-bg: var(--color-dark-darker);
    --support-message-text: var(--color-light-lighter);
    --space-md: 1rem;
    --space-lg: 1.5rem;
    --space-xl: 2rem;
    --space-xxl: 3rem;
    --line-tight: 1.2;
    --line-base: 1.5;
    --line-loose: 1.75;
    --font-sans: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell,
      Noto Sans, sans-serif;
    --font-mono: ui-monospace, "SFMono-Regular", "SF Mono", Menlo, Monaco,
      Consolas, monospace;
    --ts-xxs: clamp(0.75rem, -4cqw + 0.35rem, 0.9rem);
    --ts-xs: clamp(0.81rem, -3cqw + 0.35rem, 1.035rem);
    --ts-sm: clamp(0.9113rem, -1.5cqw + 0.35rem, 1.1644rem);
    --ts-base: clamp(1.0125rem, 0cqw + 0.35rem, 1.2938rem);
    --ts-md: clamp(1.1391rem, 1.5cqw + 0.35rem, 1.4555rem);
    --ts-lg: clamp(1.2656rem, 3cqw + 0.35rem, 1.6172rem);
    --ts-xl: clamp(1.582rem, 6cqw + 0.35rem, 2.0215rem);
    --ts-xxl: clamp(1.9775rem, 9cqw + 0.35rem, 2.5269rem);
    --ts-xxxl: clamp(2.4719rem, 12cqw + 0.35rem, 3.1586rem);
    --comparator-duration: 400vh;
    --comparator-offset: 35vh;
    --comparator-max-width: 56.25rem;
    --comparator-max-height: 100vh;
    --comparator-aspect-ratio: 4/3;
    accent-color: var(--color-accent);
  }
  @media (max-width: 48em) {
    :root {
      --comparator-aspect-ratio: 3/4;
    }
  }
  @media (prefers-color-scheme: dark) {
    :root {
      --color-bg: var(--color-dark);
      --color-bg-alt: var(--color-dark-lighter);
      --color-text: var(--color-light);
      --color-text-muted: var(--color-light-darker);
      --color-accent: color-mix(in oklch, var(--color-dark), #fff 75%);
      --support-message-bg: var(--color-light-darker);
      --support-message-text: var(--color-dark-lighter);
    }
  }
  ::selection {
    background: var(--color-accent);
    color: var(--color-bg);
  }
  body {
    background: linear-gradient(
      to bottom,
      var(--color-bg),
      color-mix(in oklch, var(--color-bg), var(--color-text) 20%)
    );
    color: var(--color-text);
    font-family: var(--font-sans);
    font-size: var(--ts-base);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: var(--line-base);
    min-block-size: 100vh;
    padding-block-start: var(--space-xl);
    text-rendering: optimizeLegibility;
  }
}
@layer layout {
  .scroll-section {
    block-size: calc(var(--comparator-duration) + 100vh);
    position: relative;
  }
  .spacer {
    block-size: 50vh;
  }
  .scroll-indicator {
    font-size: var(--ts-xl);
    max-inline-size: 100%;
    text-align: center;
  }
}
@layer comparator {
  .comparator-container {
    align-items: center;
    block-size: 100vh;
    display: flex;
    inset-block-start: 0;
    justify-content: center;
    overflow: hidden;
    position: sticky;
  }
  .comparator-wrapper {
    animation: comparator-3d-flip linear both;
    animation-range: calc(var(--comparator-offset) - 50vh)
      calc(var(--comparator-offset) + var(--comparator-duration) + 50vh);
    animation-timeline: scroll(root);
    aspect-ratio: var(--comparator-aspect-ratio);
    border-radius: 0.5rem;
    inline-size: 100%;
    margin-inline: var(--space-md);
    max-block-size: var(--comparator-max-height);
    max-inline-size: var(--comparator-max-width);
    overflow: hidden;
    position: relative;
  }
  .comparator-wrapper.flip-reverse {
    animation-name: comparator-3d-flip-reverse;
  }
  .comparator {
    animation: progress-calc linear both;
    animation-range: var(--comparator-offset)
      calc(var(--comparator-offset) + var(--comparator-duration));
    animation-timeline: scroll(root);
    block-size: 100%;
    display: grid;
    inline-size: 100%;
    position: relative;
  }
  .image-layers,
  .divider-lines {
    grid-area: 1 / -1;
    display: grid;
    position: relative;
  }
  .image-layer {
    display: grid;
    grid-area: 1 / -1;
    position: relative;
    z-index: calc(sibling-count() - sibling-index() + 1);
  }
  .image-layer:not(:last-child) {
    --layer-index: sibling-index();
    --layer-count: sibling-count();
    --layer-start: calc((var(--layer-index) - 1) / (var(--layer-count) - 1));
    --layer-end: calc(var(--layer-index) / (var(--layer-count) - 1));
    animation: clip-reveal linear both;
    animation-timeline: scroll(root);
    animation-range: calc(
        var(--comparator-offset) + (var(--comparator-duration) * var(--layer-start))
      )
      calc(
        var(--comparator-offset) + (var(--comparator-duration) * var(--layer-end))
      );
  }
  picture {
    grid-area: 1 / -1;
    max-block-size: var(--comparator-max-height);
    inline-size: 100%;
    block-size: 100%;
    display: block;
  }
  .image-layer img {
    block-size: 100%;
    display: block;
    inline-size: 100%;
    object-fit: cover;
    object-position: center;
    aspect-ratio: var(--comparator-aspect-ratio);
    background: color-mix(in oklch, var(--color-bg), var(--color-text) 5%);
  }
  .divider-line {
    --divider-index: sibling-index();
    --divider-count: sibling-count();
    --layer-start: calc((var(--divider-index) - 1) / var(--divider-count));
    --layer-end: calc(var(--divider-index) / var(--divider-count));
    background: transparent;
    block-size: 100%;
    border-inline-start: thin solid var(--color-bg);
    box-shadow: 0 0 10px color-mix(in srgb, var(--color-accent), transparent 50%);
    grid-area: 1 / -1;
    inline-size: 1px;
    pointer-events: none;
    position: relative;
    z-index: calc(20 - var(--divider-index));
    animation: divider-move linear both;
    animation-timeline: scroll(root);
    animation-range: calc(
        var(--comparator-offset) + (var(--comparator-duration) * var(--layer-start))
      )
      calc(
        var(--comparator-offset) + (var(--comparator-duration) * var(--layer-end))
      );
  }
  .comparator-overlay {
    block-size: 100%;
    display: flex;
    flex-direction: column;
    grid-area: 1 / -1;
    inline-size: 100%;
    max-block-size: var(--comparator-max-height);
    position: relative;
    transform: translateZ(30px);
  }
  .label {
    backdrop-filter: blur(0.375rem);
    background: color-mix(in srgb, var(--color-dark), transparent 20%);
    border-radius: 1rem;
    color: var(--color-light);
    font-size: var(--ts-xxs);
    font-weight: 600;
    inline-size: fit-content;
    letter-spacing: 0.05em;
    margin-block: var(--space-md) auto;
    margin-inline: var(--space-md) auto;
    padding: 0.375rem 0.75rem;
    pointer-events: none;
    position: relative;
    text-transform: uppercase;
    white-space: nowrap;
    z-index: 11;
  }
  .image-text {
    animation: text-reveal 0.6s ease-out both;
    animation-range: var(--comparator-offset)
      calc(var(--comparator-offset) + 20vh);
    animation-timeline: scroll(root);
    margin-block: auto var(--space-md);
    margin-inline: var(--space-md) auto;
    pointer-events: none;
    position: relative;
    white-space: nowrap;
    z-index: 10;
  }
  .image-text h2 {
    font-size: var(--ts-lg);
    font-weight: 500;
    letter-spacing: -0.03em;
    line-height: 1.2;
    margin: 0;
    color: var(--color-light-lighter);
  }
  .image-text h3 {
    font-size: var(--ts-md);
    font-weight: 400;
    line-height: 1.4;
    margin: 0;
    color: var(--color-light);
  }
  .comparison-percentage {
    color: var(--color-light-lighter);
    bottom: var(--space-md);
    font-size: var(--ts-md);
    font-variant-numeric: tabular-nums;
    font-weight: 500;
    line-height: 1.4;
    pointer-events: none;
    position: absolute;
    right: var(--space-md);
    z-index: 20;
  }
  @keyframes comparator-3d-flip {
    0% {
      opacity: 0.75;
      transform: perspective(1200px) rotateX(10deg) rotateY(-10deg) rotateZ(-3deg)
        scale(0.85);
    }
    15%,
    85% {
      opacity: 1;
      transform: perspective(1200px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)
        scale(1);
    }
    100% {
      opacity: 0.75;
      transform: perspective(1200px) rotateX(-10deg) rotateY(10deg) rotateZ(3deg)
        scale(0.85);
    }
  }
  @keyframes comparator-3d-flip-reverse {
    0% {
      opacity: 0.75;
      transform: perspective(1200px) rotateX(-10deg) rotateY(10deg) rotateZ(3deg)
        scale(0.85);
    }
    15%,
    85% {
      opacity: 1;
      transform: perspective(1200px) rotateX(0deg) rotateY(0deg) rotateZ(0deg)
        scale(1);
    }
    100% {
      opacity: 0.75;
      transform: perspective(1200px) rotateX(10deg) rotateY(-10deg) rotateZ(-3deg)
        scale(0.85);
    }
  }
  @keyframes progress-calc {
    from {
      --scroll-progress: 0;
    }
    to {
      --scroll-progress: 100;
    }
  }
  @keyframes clip-reveal {
    from {
      clip-path: inset(0 0 0 0);
    }
    to {
      clip-path: inset(0 100% 0 0);
    }
  }
  @keyframes divider-move {
    0% {
      inset-inline-start: 100%;
      opacity: 0;
    }
    2% {
      opacity: 1;
    }
    98% {
      opacity: 1;
    }
    100% {
      inset-inline-start: 0%;
      opacity: 0;
    }
  }
  @keyframes text-reveal {
    0% {
      opacity: 0;
      transform: translateY(20px);
    }
    100% {
      opacity: 1;
      transform: translateY(0);
    }
  }
}
@layer navigation {
  .stage-nav {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    position: absolute;
    right: var(--space-md);
    top: 50%;
    transform: translateY(-50%);
    z-index: 25;
    pointer-events: auto;
  }
  .stage-indicator {
    appearance: none;
    background: color-mix(in srgb, var(--color-light), transparent 70%);
    border: none;
    border-radius: 0.25rem;
    cursor: pointer;
    height: 0.5rem;
    padding: 0;
    transition: all 0.2s ease;
    width: 0.5rem;
  }
  .stage-indicator:hover {
    background: color-mix(in srgb, var(--color-light), transparent 30%);
    transform: scale(1.3);
  }
  .stage-indicator.active {
    background: var(--color-light);
    height: 1rem;
  }
  .stage-indicator:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }
  @media (max-width: 48em) {
    .stage-nav {
      flex-direction: row;
      right: 50%;
      top: auto;
      bottom: calc(var(--space-md) * 3);
      transform: translateX(50%);
    }
    .stage-indicator.active {
      height: 0.5rem;
      width: 1rem;
    }
  }
}

3. The JavaScript handles the physics-based scrolling (smoothing), generates the navigation dots, and updates the percentage text.

(function () {
  "use strict";
  // Velocity tracking for smooth scrolling
  let velocity = 0;
  const ease = 0.12;
  const friction = 0.92;
  // Cache DOM elements
  const sections = document.querySelectorAll(".scroll-section");
  const sectionsLen = sections.length;
  const wrappers = [];
  const comparatorData = [];
  let i, s, w, c, p;
  // Build data structures for each comparator
  for (i = 0; i < sectionsLen; i++) {
    s = sections[i];
    w = s.querySelector(".comparator-wrapper");
    if (w) wrappers.push({ section: s, wrapper: w });
    c = s.querySelector(".comparator");
    if (!c) continue;
    p = c.querySelector(".comparison-percentage");
    if (p) {
      const layers = c.querySelectorAll(".image-layer");
      comparatorData.push({
        comp: c,
        pct: p,
        section: s,
        layerCount: layers.length,
        wrapper: w
      });
    }
  }
  const wrappersLen = wrappers.length;
  const compLen = comparatorData.length;
  let d, v;
  // Create stage indicator buttons
  function createStageIndicators() {
    for (i = 0; i < compLen; i++) {
      d = comparatorData[i];
      const nav = document.createElement("div");
      nav.className = "stage-nav";
      const indicators = [];
      for (let j = 0; j < d.layerCount; j++) {
        const indicator = document.createElement("button");
        indicator.className = "stage-indicator";
        indicator.setAttribute("aria-label", `Go to stage ${j + 1}`);
        indicator.dataset.stage = j;
        indicator.dataset.comparatorIndex = i;
        indicators.push(indicator);
        nav.appendChild(indicator);
      }
      d.comp.appendChild(nav);
      d.indicators = indicators;
    }
  }
  // Calculate total scroll distance from CSS variable
  function getComparatorDuration() {
    const style = getComputedStyle(document.documentElement);
    const duration = style.getPropertyValue("--comparator-duration").trim();
    return (parseFloat(duration) * window.innerHeight) / 100;
  }
  let targetScrollPosition = null;
  const scrollEase = 0.08;
  // Navigate to specific stage programmatically
  function scrollToStage(comparatorIndex, stageIndex) {
    const data = comparatorData[comparatorIndex];
    if (!data) return;
    const offset = data.section.offsetTop;
    const duration = getComparatorDuration();
    const stageCount = data.layerCount;
    // Clamp stage index to valid range
    stageIndex = Math.max(0, Math.min(stageIndex, stageCount - 1));
    const stageDuration = duration / (stageCount - 1);
    targetScrollPosition = offset + stageDuration * stageIndex;
  }
  // Handle stage indicator clicks
  function onIndicatorClick(e) {
    const btn = e.target.closest(".stage-indicator");
    if (!btn) return;
    const stage = parseInt(btn.dataset.stage, 10);
    const compIndex = parseInt(btn.dataset.comparatorIndex, 10);
    scrollToStage(compIndex, stage);
  }
  // Update CSS custom property for scroll offset
  function updateOffsets() {
    for (i = 0; i < wrappersLen; i++) {
      w = wrappers[i];
      w.wrapper.style.setProperty(
        "--comparator-offset",
        w.section.offsetTop + "px"
      );
    }
  }
  // Accumulate scroll velocity
  function onWheel(e) {
    e.preventDefault();
    targetScrollPosition = null;
    velocity += e.deltaY;
  }
  let resizeTimeout;
  // Recalculate offsets on resize
  function onResize() {
    targetScrollPosition = null;
    clearTimeout(resizeTimeout);
    resizeTimeout = setTimeout(() => {
      updateOffsets();
    }, 150);
  }
  // Cancel programmatic scroll on manual interaction
  function onMouseDown(e) {
    if (!e.target.closest(".comparator-wrapper")) {
      targetScrollPosition = null;
    }
  }
  // Animation loop updates percentage and indicators
  function frame() {
    if (targetScrollPosition !== null) {
      const current = window.scrollY;
      const delta = targetScrollPosition - current;
      if (Math.abs(delta) > 1) {
        window.scrollBy(0, delta * scrollEase);
      } else {
        targetScrollPosition = null;
      }
    }
    // Apply velocity-based scrolling
    velocity *= friction;
    if (velocity > 0.2 || velocity < -0.2) {
      window.scrollBy(0, velocity * ease);
    }
    // Update percentage display and indicators
    for (i = 0; i < compLen; i++) {
      d = comparatorData[i];
      v =
        parseFloat(
          getComputedStyle(d.comp).getPropertyValue("--scroll-progress")
        ) || 0;
      d.pct.textContent = (Math.round(v) + "").padStart(2, "0") + "%";
      const currentStage = Math.round((v / 100) * (d.layerCount - 1));
      d.indicators.forEach((indicator, idx) => {
        indicator.classList.toggle("active", idx === currentStage);
      });
    }
    requestAnimationFrame(frame);
  }
  window.addEventListener("wheel", onWheel, { passive: false });
  window.addEventListener("resize", onResize, { passive: true });
  window.addEventListener("mousedown", onMouseDown, { passive: true });
  document.addEventListener("click", onIndicatorClick);
  window.addEventListener("load", () => {
    createStageIndicators();
    updateOffsets();
    requestAnimationFrame(frame);
  });
})();

4. Configuration options.

  • --comparator-duration (CSS custom property, viewport height units): Controls the total scroll distance required to complete the full animation sequence. Default is 400vh. Increase this value for slower transitions or decrease for faster reveal effects.
  • --comparator-offset (CSS custom property, viewport height units): Determines the scroll position where the animation begins. Default is 35vh. This value gets updated dynamically by JavaScript based on the section’s offsetTop position.
  • --comparator-max-width (CSS custom property, rem units): Sets the maximum width constraint for the comparator container. Default is 56.25rem. The comparator scales responsively but never exceeds this width.
  • --comparator-max-height (CSS custom property, viewport height units): Defines the maximum height for the comparator. Default is 100vh. This prevents the comparator from exceeding the viewport height on tall screens.
  • --comparator-aspect-ratio (CSS custom property, ratio): Controls the aspect ratio of the comparator frame. Default is 4/3 for desktop and 3/4 for mobile viewports below 48em. Adjust this to match your image dimensions.
  • ease (JavaScript constant): The easing multiplier applied to velocity-based scrolling. Default is 0.12. Lower values create smoother but slower scroll response. Higher values increase scroll sensitivity.
  • friction (JavaScript constant): The decay rate for scroll velocity. Default is 0.92. Values closer to 1.0 maintain momentum longer. Values closer to 0 stop scrolling more abruptly.
  • scrollEase (JavaScript constant): The easing factor for programmatic scroll-to-stage navigation. Default is 0.08. This controls how quickly the page scrolls when clicking stage indicators.

FAQs

Q: Does this work on all browsers?
A: This relies on scroll-timeline and @property. These are supported in modern Chrome, Edge, and newer versions of Safari and Firefox.

Q: Can I use images with different aspect ratios?
A: The CSS enforces an aspect ratio (default 4/3) on the container. The images use object-fit: cover. This fills the container completely. You can adjust the --comparator-aspect-ratio variable in the CSS to match your specific image dimensions.

Q: Why does the page scroll feel different?
A: The JavaScript includes a custom velocity and friction model. This hijacks the default scroll behavior to provide a smoother, “weightier” feel that matches the animation speed.

Q: How do I add more stages?
A: You simply add more .image-layer divs in the HTML. The CSS uses sibling-count() logic (or the manual calculation in the provided CSS) to automatically recalculate the timing windows for the new total number of layers.

You Might Be Interested In:


Leave a Reply