Multi-Stage Image Comparison Carousel with CSS Scroll-timeline

Multi-Stage Image Comparison Carousel with CSS Scroll-timeline
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.

The post Multi-Stage Image Comparison Carousel with CSS Scroll-timeline appeared first on CSS Script.


Discover more from RSS Feeds Cloud

Subscribe to get the latest posts sent to your email.

Discover more from RSS Feeds Cloud

Subscribe now to keep reading and get access to the full archive.

Continue reading