Two pictures stacked in one frame with a draggable divider that wipes between them. The handle follows the pointer on a spring, the Before and After labels fade as their side closes, and the divider sweeps gently from side to side until someone touches it. The demo shows a garden makeover drawn in SVG, with a heading above and three project facts below. On phones the frame becomes 4:3 and the handle works with a finger while the page still scrolls up and down.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/image-comparison)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } from "framer-motion";
// Where the divider rests (percent from the left) and how the handle follows the pointer.
const START = 50;
const SPRING = { stiffness: 420, damping: 38, mass: 0.6 };
const facts = [
{ label: "Plot", value: "9 x 14 m" },
{ label: "Build time", value: "11 days" },
{ label: "Planting", value: "64 plants" },
];
/** One back garden, drawn twice: tired on the left, finished on the right. Swap for two images of the same size. */
function Garden({ after }: { after: boolean }) {
const id = after ? "after" : "before";
return (
<svg viewBox="0 0 800 450" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="absolute inset-0 h-full w-full">
<defs>
<linearGradient id={`compare-sky-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={after ? "#1e3a8a" : "#94a3b8"} />
<stop offset="1" stopColor={after ? "#fdba74" : "#cbd5e1"} />
</linearGradient>
<linearGradient id={`compare-ground-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={after ? "#4d7c0f" : "#a8a29e"} />
<stop offset="1" stopColor={after ? "#1a2e05" : "#57534e"} />
</linearGradient>
</defs>
<rect width="800" height="450" fill={`url(#compare-sky-${id})`} />
{after && <circle cx="610" cy="150" r="46" fill="#fed7aa" />}
{/* House */}
<path d="M40 250V110l110-70 110 70v140Z" fill={after ? "#292524" : "#78716c"} />
<rect x="92" y="150" width="50" height="60" fill={after ? "#fde68a" : "#a8a29e"} />
<rect x="170" y="150" width="50" height="100" fill={after ? "#fbbf24" : "#57534e"} />
{/* Fence */}
<rect x="0" y="196" width="800" height="70" fill={after ? "#44403c" : "#a8a29e"} />
{Array.from({ length: 27 }).map((_, index) => (
<rect key={index} x={index * 30 + 4} y={after ? 190 : 190 + (index % 4) * 3} width="22" height="76" fill={after ? "#57534e" : index % 5 === 2 ? "#78716c" : "#d6d3d1"} />
))}
{/* Ground */}
<rect y="262" width="800" height="188" fill={`url(#compare-ground-${id})`} />
{after ? (
<>
{[[300, 424, 46], [352, 372, 38], [392, 330, 30], [424, 298, 24]].map(([cx, cy, rx]) => (
<ellipse key={cx} cx={cx} cy={cy} rx={rx} ry={rx * 0.34} fill="#e7e5e4" />
))}
<rect x="560" y="300" width="190" height="96" rx="8" fill="#78350f" />
<rect x="574" y="286" width="162" height="26" rx="6" fill="#fef3c7" />
<circle cx="470" cy="350" r="30" fill="#1c1917" />
<circle cx="470" cy="346" r="16" fill="#f97316" />
<circle cx="470" cy="342" r="8" fill="#fde047" />
{[60, 130, 205, 280, 690, 760].map((x, index) => (
<g key={x}>
<ellipse cx={x} cy={272} rx="44" ry="30" fill={index % 2 ? "#166534" : "#15803d"} />
<circle cx={x - 14} cy={262} r="6" fill="#fda4af" />
<circle cx={x + 12} cy={270} r="6" fill="#fef08a" />
</g>
))}
<path d="M260 96c120 60 300 60 540 4" stroke="#1c1917" strokeWidth="2" fill="none" />
{[300, 360, 420, 480, 540, 600, 660, 720].map((x, index) => (
<circle key={x} cx={x} cy={126 + Math.sin((index + 1) * 0.42) * 14} r="6" fill="#fde68a" />
))}
</>
) : (
<>
<path d="M120 340h220l30 60H90Z" fill="#78716c" opacity="0.6" />
<rect x="560" y="300" width="120" height="70" fill="#57534e" />
<path d="M556 300h128l-12-22H568Z" fill="#44403c" />
<path d="M400 300 420 262l14 38Zm90 100 16-30 16 30Zm220-90 12-24 12 24Z" fill="#65a30d" opacity="0.5" />
<ellipse cx="300" cy="420" rx="120" ry="14" fill="#44403c" opacity="0.5" />
<path d="M40 300h80v8H40Zm640 96h90v8h-90Z" fill="#44403c" />
</>
)}
</svg>
);
}
export default function BeforeAndAfterComparisonSliderWithSpringHandle() {
const reduceMotion = useReducedMotion();
const frameRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const idle = useRef<ReturnType<typeof animate> | null>(null);
const target = useMotionValue(START);
const position = useSpring(target, SPRING);
// The finished garden is clipped away to the right of the divider.
const clip = useTransform(position, (value) => `inset(0 ${100 - value}% 0 0)`);
const left = useTransform(position, (value) => `${value}%`);
const afterLabel = useTransform(position, [8, 22], [0, 1]);
const beforeLabel = useTransform(position, [78, 92], [1, 0]);
useMotionValueEvent(position, "change", (value) => {
handleRef.current?.setAttribute("aria-valuenow", String(Math.round(value)));
});
// Idle sweep so the comparison shows itself. It stops for good on the first real input.
useEffect(() => {
if (reduceMotion) return;
idle.current = animate(target, [START, 26, 74, START], { duration: 5.5, ease: "easeInOut", repeat: Infinity, delay: 0.5 });
return () => idle.current?.stop();
}, [reduceMotion, target]);
const stopIdle = () => {
idle.current?.stop();
idle.current = null;
};
const moveTo = (clientX: number) => {
const rect = frameRef.current?.getBoundingClientRect();
if (!rect) return;
target.set(Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100)));
};
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
stopIdle();
dragging.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
moveTo(event.clientX);
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (dragging.current) moveTo(event.clientX);
};
const onPointerUp = () => {
dragging.current = false;
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const steps: Record<string, number> = { ArrowLeft: -4, ArrowRight: 4, PageDown: -20, PageUp: 20 };
let next: number | null = null;
if (event.key in steps) next = target.get() + steps[event.key];
if (event.key === "Home") next = 0;
if (event.key === "End") next = 100;
if (next === null) return;
event.preventDefault();
stopIdle();
target.set(Math.min(100, Math.max(0, next)));
};
return (
<section className="bg-emerald-950 px-5 py-14 text-emerald-50 sm:px-8 sm:py-16">
<div className="mx-auto max-w-5xl">
<div className="flex flex-wrap items-end justify-between gap-5">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-lime-300">Mossbank Gardens, project 041</p>
<h2 className="mt-3 max-w-xl text-3xl font-bold tracking-tight sm:text-5xl">Same fence line. Eleven days apart.</h2>
</div>
<p className="max-w-xs text-sm leading-6 text-emerald-100">Drag the handle, or focus it and use the arrow keys, to wipe between the yard we found and the one we left.</p>
</div>
<div
ref={frameRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative mt-9 aspect-[4/3] cursor-ew-resize touch-pan-y select-none overflow-hidden rounded-3xl bg-stone-400 shadow-2xl shadow-black/40 ring-1 ring-white/10 sm:aspect-[2/1]"
>
<Garden after={false} />
<motion.div className="absolute inset-0" style={{ clipPath: clip }}>
<Garden after />
</motion.div>
<motion.span style={{ opacity: afterLabel }} className="pointer-events-none absolute left-4 top-4 rounded-full bg-emerald-950/80 px-3 py-1 text-xs font-semibold text-lime-200">
After
</motion.span>
<motion.span style={{ opacity: beforeLabel }} className="pointer-events-none absolute right-4 top-4 rounded-full bg-white/85 px-3 py-1 text-xs font-semibold text-stone-800">
Before
</motion.span>
{/* Divider and handle */}
<motion.div className="absolute inset-y-0 w-0" style={{ left }}>
<div aria-hidden="true" className="absolute inset-y-0 -left-px w-0.5 bg-white shadow-[0_0_12px_rgba(0,0,0,0.5)]" />
<div
ref={handleRef}
role="slider"
tabIndex={0}
aria-label="Show more of the before or after picture"
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={START}
onKeyDown={onKeyDown}
className="absolute top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white text-emerald-950 shadow-xl ring-4 ring-white/30 transition-transform duration-200 hover:scale-110 focus:outline-none focus-visible:ring-4 focus-visible:ring-lime-300 motion-reduce:transition-none"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
<path d="m9 7-5 5 5 5m6-10 5 5-5 5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</motion.div>
</div>
<dl className="mt-7 grid grid-cols-3 gap-4 border-t border-white/10 pt-6">
{facts.map((fact) => (
<div key={fact.label}>
<dt className="text-xs uppercase tracking-widest text-emerald-200">{fact.label}</dt>
<dd className="mt-1 text-lg font-semibold text-white sm:text-2xl">{fact.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/before-and-after-comparison-slider-with-spring-handle.json
Both pictures fill the same box. The top one has a `clip-path: inset(...)` whose right edge is tied to the divider position, so moving the divider reveals more or less of it. Nothing is resized, which keeps the two pictures lined up.
Changing the width of a wrapper forces the browser to lay out the picture again and can make it shift. A clip path only changes what is painted, so the wipe stays smooth.
Yes. It uses pointer events with pointer capture, so a finger can drag the divider. `touch-pan-y` lets the page keep scrolling vertically when the gesture goes up or down.
Yes. In `onPointerMove`, call `moveTo` whenever the pointer type is a mouse instead of only while dragging. Keep the drag behaviour for touch.
Yes. Use the pointer's Y position, change the clip path to `inset(0 0 ${100 - value}% 0)` and set the divider's `top` instead of `left`.
Adapted from Image Comparison in Motion Primitives, used under its open-source licence. Licences and credits
Related: Coverflow Gallery with 3D Perspective and Autoplay, 3D Tilt Card with Parallax Layers and Glare, Alternating Feature Rows with Illustrations.
More galleries and carousels or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.