
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.







