An image comparison slider on a clean white section. Two versions of the same picture are stacked, and a white handle with a round grip wipes between them. When the slider scrolls into view the handle sweeps left and right once by itself, so visitors see that it moves, then waits with a soft pulse until someone takes over. It works with a mouse, a finger and the arrow keys. Three preset buttons change the grade on the After side. The demo picture is drawn in SVG; swap in your own two images. On phones the frame becomes taller and vertical scrolling still works over it.
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "framer-motion";
const START = 50;
const SWEEP = { stops: [START, 16, 84, START], seconds: 3.4 };
const BEFORE_FILTER = "grayscale(0.8) contrast(0.7) brightness(1.12)";
const presets = [
{ name: "Golden hour", filter: "none", note: "Warm highlights, deep plum shadows" },
{ name: "Blue hour", filter: "hue-rotate(205deg) saturate(0.85)", note: "Cool sky, teal water, soft contrast" },
{ name: "Silver film", filter: "grayscale(1) contrast(1.3) brightness(0.95)", note: "High-contrast black and white" },
];
/** The demo photo, drawn in SVG so nothing is loaded. Swap both copies for your own <img>. */
function Scene({ id }: { id: string }) {
return (
<svg viewBox="0 0 800 500" preserveAspectRatio="xMidYMid slice" className="h-full w-full" aria-hidden="true">
<defs>
<linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#1b1445" />
<stop offset="0.38" stopColor="#5b2a86" />
<stop offset="0.66" stopColor="#e2557a" />
<stop offset="0.86" stopColor="#ffa552" />
<stop offset="1" stopColor="#ffd98a" />
</linearGradient>
<radialGradient id={`${id}-glow`}>
<stop offset="0" stopColor="#fff1c4" stopOpacity="0.9" />
<stop offset="0.35" stopColor="#ffc46b" stopOpacity="0.45" />
<stop offset="1" stopColor="#ffb057" stopOpacity="0" />
</radialGradient>
<linearGradient id={`${id}-far`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#6a3a86" />
<stop offset="1" stopColor="#d9668a" />
</linearGradient>
<linearGradient id={`${id}-near`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3a2058" />
<stop offset="1" stopColor="#7d3b78" />
</linearGradient>
<linearGradient id={`${id}-lake`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#ff9d5c" />
<stop offset="0.3" stopColor="#b8457a" />
<stop offset="1" stopColor="#1d1238" />
</linearGradient>
</defs>
<rect width="800" height="345" fill={`url(#${id}-sky)`} />
{[[70, 40], [150, 95], [260, 30], [345, 80], [430, 26], [610, 52], [700, 110], [760, 34]].map(([x, y]) => (
<circle key={`${x}-${y}`} cx={x} cy={y} r="1.6" fill="#fff" opacity="0.7" />
))}
<ellipse cx="220" cy="190" rx="150" ry="13" fill="#ff8fa3" opacity="0.45" />
<ellipse cx="300" cy="212" rx="95" ry="8" fill="#ffc0a0" opacity="0.5" />
<ellipse cx="650" cy="170" rx="120" ry="10" fill="#ff8fa3" opacity="0.4" />
<circle cx="540" cy="262" r="210" fill={`url(#${id}-glow)`} />
<circle cx="540" cy="262" r="44" fill="#fff4cf" />
<path d="M0 330C40 318 70 262 110 258s60 44 100 40 70-78 110-80 70 78 110 84 60-30 100-26 60 40 90 34 70-70 110-72 50 60 70 74v108H0Z" fill={`url(#${id}-far)`} />
<path d="M0 350c50-10 80-58 130-60s70 46 120 44 80-56 130-56 80 60 130 62 90-50 140-50 100 44 150 40v40H0Z" fill={`url(#${id}-near)`} />
<rect y="345" width="800" height="155" fill={`url(#${id}-lake)`} />
{[[540, 356, 150], [540, 370, 104], [540, 384, 130], [540, 400, 72], [540, 418, 96], [540, 438, 48], [540, 458, 64]].map(([x, y, w]) => (
<rect key={y} x={x - w / 2} y={y} width={w} height="4" rx="2" fill="#ffe2a8" opacity="0.75" />
))}
<path d="M0 500V410c60-22 120-14 180 6 50 16 90 30 140 84Z" fill="#140d26" />
<path d="M800 500V396c-70-16-130-4-190 28-40 22-70 44-96 76Z" fill="#140d26" />
{[[60, 408, 1], [104, 400, 1.3], [150, 412, 0.9], [690, 398, 1.25], [742, 390, 1], [640, 416, 0.85]].map(([x, y, s]) => (
<path key={x} d={`M${x} ${y - 62 * s}l${15 * s} ${30 * s}h-${8 * s}l${13 * s} ${26 * s}h-${40 * s}l${13 * s}-${26 * s}h-${8 * s}Z`} fill="#140d26" />
))}
</svg>
);
}
export default function BeforeAfterComparisonSliderWithAutoSweep() {
const [preset, setPreset] = useState(0);
const [value, setValue] = useState(START);
const [touched, setTouched] = useState(false);
const frame = useRef<HTMLDivElement>(null);
const dragging = useRef(false);
const sweep = useRef<{ stop: () => void } | null>(null);
const still = !!useReducedMotion();
const inView = useInView(frame, { amount: 0.5, once: true });
// One number (0 to 100) drives the clip, the handle and the readout.
const position = useMotionValue(START);
const clip = useTransform(position, (v) => `inset(0 ${100 - v}% 0 0)`);
const handleX = useTransform(position, (v) => `${v}%`);
useMotionValueEvent(position, "change", (v) => setValue(Math.round(v)));
// Sweep once when the slider scrolls into view, so people see that it moves.
useEffect(() => {
if (!inView || still) return;
const controls = animate(position, SWEEP.stops, { duration: SWEEP.seconds, times: [0, 0.32, 0.72, 1], ease: "easeInOut", delay: 0.3 });
sweep.current = controls;
return () => controls.stop();
}, [inView, still, position]);
const takeOver = () => {
sweep.current?.stop();
setTouched(true);
};
const moveTo = (clientX: number) => {
const box = frame.current?.getBoundingClientRect();
if (!box) return;
position.set(Math.min(100, Math.max(0, ((clientX - box.left) / box.width) * 100)));
};
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
takeOver();
dragging.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
moveTo(e.clientX);
};
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (dragging.current) moveTo(e.clientX);
};
const onPointerUp = () => {
dragging.current = false;
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 10 : 2;
const targets: Record<string, number> = { ArrowLeft: position.get() - step, ArrowRight: position.get() + step, Home: 0, End: 100 };
if (!(e.key in targets)) return;
e.preventDefault();
takeOver();
position.set(Math.min(100, Math.max(0, targets[e.key])));
};
return (
<section className="bg-white px-5 py-16 text-slate-900 sm:px-6 sm:py-24">
<style>{`
@keyframes before-after-slider-pulse { 0% { transform: scale(1); opacity: 0.55; } 100% { transform: scale(1.9); opacity: 0; } }
.before-after-slider-pulse { animation: before-after-slider-pulse 1.8s ease-out infinite; }
@media (prefers-reduced-motion: reduce) { .before-after-slider-pulse { animation: none; opacity: 0; } }
`}</style>
<div className="mx-auto max-w-4xl">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
<div className="max-w-lg">
<p className="text-sm font-semibold text-rose-600">Lumen presets</p>
<h2 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">One tap between flat and finished.</h2>
<p className="mt-4 text-lg text-slate-600">Drag the handle to compare the file from the camera with the graded photo.</p>
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Preset">
{presets.map((p, i) => (
<button
key={p.name}
type="button"
aria-pressed={preset === i}
onClick={() => setPreset(i)}
className={`min-h-[40px] rounded-full border px-4 text-sm font-medium outline-none transition focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 ${preset === i ? "border-slate-900 bg-slate-900 text-white" : "border-slate-300 text-slate-700 hover:border-slate-900"}`}
>
{p.name}
</button>
))}
</div>
</div>
<div
ref={frame}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative mt-10 aspect-[4/3] w-full cursor-ew-resize select-none overflow-hidden rounded-3xl bg-slate-900 shadow-2xl shadow-slate-300 sm:aspect-[16/9]"
style={{ touchAction: "pan-y" }}
>
{/* After: the full picture underneath. */}
<div className="absolute inset-0 transition-[filter] duration-500" style={{ filter: presets[preset].filter }}>
<Scene id="before-after-slider-after" />
</div>
<span className="absolute right-4 top-4 rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-900 shadow">After</span>
{/* Before: the same picture, flattened, clipped to the left of the handle. */}
<motion.div className="absolute inset-0" style={{ clipPath: clip }}>
<div className="absolute inset-0" style={{ filter: BEFORE_FILTER }}>
<Scene id="before-after-slider-before" />
</div>
<div className="absolute inset-0 bg-slate-400 opacity-25" aria-hidden="true" />
<span className="absolute left-4 top-4 rounded-full bg-slate-900/70 px-3 py-1 text-xs font-semibold text-white">Before</span>
</motion.div>
{/* Handle: a full-size layer shifted by a percentage, so only transform changes. */}
<motion.div className="pointer-events-none absolute inset-0" style={{ x: handleX }}>
<span className="absolute inset-y-0 left-0 w-0.5 -translate-x-1/2 bg-white shadow-[0_0_12px_rgba(255,255,255,0.8)]" aria-hidden="true" />
<div
role="slider"
tabIndex={0}
aria-label="Position of the before and after divider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
aria-valuetext={`${value}% before, ${100 - value}% after`}
onKeyDown={onKeyDown}
className="pointer-events-auto absolute left-0 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-slate-900 shadow-lg outline-none focus-visible:ring-4 focus-visible:ring-rose-500"
>
{!touched && <span className="before-after-slider-pulse absolute inset-0 rounded-full bg-white" aria-hidden="true" />}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="relative h-5 w-5" aria-hidden="true">
<path d="m9 7-5 5 5 5M15 7l5 5-5 5" />
</svg>
</div>
</motion.div>
</div>
<div className="mt-5 flex flex-col gap-2 text-sm sm:flex-row sm:items-center sm:justify-between">
<p className="text-slate-600">
<span className="font-semibold text-slate-900">{presets[preset].name}.</span> {presets[preset].note}.
</p>
<p className="tabular-nums text-slate-600" aria-hidden="true">
Showing {100 - value}% graded
</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/before-after-comparison-slider-with-auto-sweep.json
Both images fill the same box. The top one is cut with `clip-path: inset(0 X% 0 0)`, where X depends on the handle position, so more or less of the bottom image shows through. Nothing is resized, which keeps both pictures perfectly aligned.
The frame listens for pointer events, which cover mouse, touch and pen in one API. On pointer down it captures the pointer, so the drag continues even if the cursor leaves the box, and converts the horizontal position into a percentage of the frame width.
A static comparison slider often looks like a plain image with a line on it. One short sweep shows the interaction without a hint label. It runs a single time and stops the moment someone drags or presses a key.
Yes. One motion value drives everything. The handle moves with a percentage transform and the image is revealed with `clip-path`, so no layout is recalculated while you drag.
Yes. Change the clip to `inset(0 0 X% 0)`, move the handle layer with `y` instead of `x`, read `clientY` against the frame height, and switch `touch-action` to `pan-x`.
Related: Alternating Feature Rows with Illustrations, Product Card with Price, Rating, Colour Swatches and Add to Cart, Stats Section with Radial Progress Rings.
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.