Image Comparison Slider
A before/after reveal with a glass grip on a spring-driven divider: press anywhere to glide it there, drag without a jump, use the arrow keys, or turn on hover mode. Corner labels fade as the divider reaches them, it splits left/right or top/bottom, and with no images it draws its own night-to-day lake scene. No dependencies.
DOM + CSS0 deps1 file · 24 KB TSXreduced-motion aware
Props
"use client";
/*
* ImageComparisonSlider: a before/after reveal with a spring-driven divider.
*
* Two images share one frame; the "before" layer is clipped by a CSS variable
* (--icp, 0 to 100) that a single rAF loop writes straight to the DOM, so a drag
* never re-renders React. Press anywhere to jump the divider there (it glides on
* a soft spring, then follows the pointer on a stiff one), grab the glass grip to
* drag without a jump, use the arrow keys, or switch on `hover` so it follows a
* mouse without a press. The corner labels fade out as the divider reaches them.
* Without images it draws a night/day mountain lake in SVG.
*/
import { useEffect, useId, useRef, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react";
export type ImageComparisonSliderProps = {
/** Image URL for the "before" side. Omit for the built-in night scene. */
before?: string;
/** Image URL for the "after" side. Omit for the built-in day scene. */
after?: string;
/** Starting divider position, 0 to 100. */
initial?: number;
labelBefore?: string;
labelAfter?: string;
/** "horizontal" splits left/right, "vertical" splits top/bottom. */
orientation?: "horizontal" | "vertical";
/** Follow a mouse without pressing. Touch and pen still drag. */
hover?: boolean;
/** Called with the divider position (0 to 100) whenever the visitor moves it. */
onChange?: (position: number) => void;
/** Extra classes on the root. The defaults are h-64 w-full max-w-xl. */
className?: string;
};
/** Accepts http(s) and relative URLs only; strips control whitespace, rejects backslashes. */
function safeSrc(value: unknown): string | null {
if (typeof value !== "string") return null;
const v = value.replace(/[\t\r\n]/g, "").trim();
if (!v || v.includes("\\")) return null;
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(v);
if (scheme) return scheme[1]?.toLowerCase() === "http" || scheme[1]?.toLowerCase() === "https" ? v : null;
return v;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/* ---- Built-in scene: the same lake at night and at dawn ------------------ */
const SCENE_W = 800;
const SCENE_H = 500;
const HORIZON = 318;
/** A smooth skyline through deterministic points. */
function ridge(base: number, amp: number, seed: number, step: number): string {
const pts: Array<[number, number]> = [];
for (let x = -step; x <= SCENE_W + step * 2; x += step) {
const h =
Math.sin(x * 0.011 + seed) * 0.55 +
Math.sin(x * 0.027 + seed * 2.3) * 0.3 +
Math.sin(x * 0.061 + seed * 4.1) * 0.15;
pts.push([Math.round(x * 10) / 10, Math.round((base - (h * 0.5 + 0.5) * amp) * 10) / 10]);
}
const first = pts[0];
if (!first) return "";
let d = "M" + first[0] + " " + first[1];
for (let i = 1; i < pts.length - 1; i++) {
const p = pts[i];
const n = pts[i + 1];
if (!p || !n) continue;
d += "Q" + p[0] + " " + p[1] + " " + Math.round(((p[0] + n[0]) / 2) * 10) / 10 + " " + Math.round(((p[1] + n[1]) / 2) * 10) / 10;
}
return d + "L" + (SCENE_W + step * 2) + " " + HORIZON + "L" + -step + " " + HORIZON + "Z";
}
const RIDGE_FAR = ridge(268, 110, 1.2, 36);
const RIDGE_MID = ridge(292, 92, 3.4, 30);
const RIDGE_NEAR = ridge(HORIZON + 4, 70, 5.9, 26);
function pine(x: number, base: number, h: number): string {
const w = h * 0.34;
const t = (k: number) => Math.round(k * 10) / 10;
return (
"M" + t(x) + " " + t(base - h) +
"L" + t(x + w * 0.5) + " " + t(base - h * 0.62) + "L" + t(x + w * 0.28) + " " + t(base - h * 0.62) +
"L" + t(x + w * 0.74) + " " + t(base - h * 0.3) + "L" + t(x + w * 0.4) + " " + t(base - h * 0.3) +
"L" + t(x + w) + " " + t(base) + "L" + t(x - w) + " " + t(base) +
"L" + t(x - w * 0.4) + " " + t(base - h * 0.3) + "L" + t(x - w * 0.74) + " " + t(base - h * 0.3) +
"L" + t(x - w * 0.28) + " " + t(base - h * 0.62) + "L" + t(x - w * 0.5) + " " + t(base - h * 0.62) + "Z"
);
}
const PINES: Array<[number, number]> = [
[34, 62], [70, 84], [108, 58], [146, 72], [660, 70], [700, 96], [742, 64], [782, 86], [820, 60], [622, 52],
];
const TREE_PATH = PINES.map(([x, h]) => pine(x, HORIZON + 14, h)).join("");
/** Deterministic stars (a tiny LCG, so server and client agree). */
const STARS: Array<[number, number, number]> = (() => {
const out: Array<[number, number, number]> = [];
let s = 7;
for (let i = 0; i < 70; i++) {
s = (s * 1103515245 + 12345) % 2147483648;
const x = (s % 800) + 0;
s = (s * 1103515245 + 12345) % 2147483648;
const y = (s % 215) + 6;
s = (s * 1103515245 + 12345) % 2147483648;
out.push([x, y, 0.5 + (s % 100) / 100]);
}
return out;
})();
function Scene({ mode, label }: { mode: "night" | "day"; label: string }) {
const id = useId().replace(/[^a-zA-Z0-9]/g, "") + mode;
const night = mode === "night";
const sky = night ? ["#03060f", "#0a1330", "#1d2c5a"] : ["#3b66b3", "#e89a8c", "#ffe3b3"];
const far = night ? "#18224a" : "#8c86bd";
const mid = night ? "#101836" : "#5d6a9e";
const near = night ? "#090e24" : "#2c3b66";
const wood = night ? "#050917" : "#14203a";
const orbX = night ? 548 : 520;
const orbY = night ? 128 : HORIZON - 30;
return (
<svg
role="img"
aria-label={label}
viewBox={"0 0 " + SCENE_W + " " + SCENE_H}
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 h-full w-full"
>
<defs>
<linearGradient id={id + "s"} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={sky[0]} />
<stop offset="0.55" stopColor={sky[1]} />
<stop offset="1" stopColor={sky[2]} />
</linearGradient>
<radialGradient id={id + "o"}>
<stop offset="0" stopColor={night ? "#dfe8ff" : "#fff1c9"} stopOpacity={night ? 0.5 : 0.95} />
<stop offset="1" stopColor={night ? "#7f9bff" : "#ffb27a"} stopOpacity="0" />
</radialGradient>
<linearGradient id={id + "w"} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={night ? "#10193c" : "#f3b79b"} />
<stop offset="1" stopColor={night ? "#04070f" : "#3a4a7c"} />
</linearGradient>
<linearGradient id={id + "m"} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={night ? "#8ea6ff" : "#fff0d0"} stopOpacity="0" />
<stop offset="0.5" stopColor={night ? "#8ea6ff" : "#fff0d0"} stopOpacity={night ? 0.22 : 0.5} />
<stop offset="1" stopColor={night ? "#8ea6ff" : "#fff0d0"} stopOpacity="0" />
</linearGradient>
<clipPath id={id + "c"}>
<rect x="0" y={HORIZON + 12} width={SCENE_W} height={SCENE_H - HORIZON} />
</clipPath>
</defs>
<rect width={SCENE_W} height={SCENE_H} fill={"url(#" + id + "s)"} />
{night &&
STARS.map(([x, y, r], i) => (
<circle key={i} cx={x} cy={y} r={r} fill="#fff" opacity={Math.round((0.35 + (i % 5) * 0.12) * 100) / 100} />
))}
<circle cx={orbX} cy={orbY} r={night ? 120 : 190} fill={"url(#" + id + "o)"} />
<circle cx={orbX} cy={orbY} r={night ? 22 : 30} fill={night ? "#eef2ff" : "#fff6dc"} />
{night && <circle cx={orbX + 9} cy={orbY - 4} r="19" fill="#0a1330" opacity="0.18" />}
<path d={RIDGE_FAR} fill={far} />
<path d={RIDGE_MID} fill={mid} />
<rect x="0" y={HORIZON - 52} width={SCENE_W} height="70" fill={"url(#" + id + "m)"} />
<path d={RIDGE_NEAR} fill={near} />
{/* the lake and its reflections */}
<rect x="0" y={HORIZON + 12} width={SCENE_W} height={SCENE_H - HORIZON} fill={"url(#" + id + "w)"} />
<g clipPath={"url(#" + id + "c)"}>
<g transform={"translate(0 " + (HORIZON * 2 + 24) + ") scale(1 -1)"} opacity={night ? 0.5 : 0.4}>
<path d={RIDGE_MID} fill={mid} />
<path d={RIDGE_NEAR} fill={near} />
</g>
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<rect
key={i}
x={orbX - 46 + i * 5}
y={HORIZON + 30 + i * 21}
width={92 - i * 10}
height={night ? 3 : 4}
rx="2"
fill={night ? "#dfe8ff" : "#fff1c9"}
opacity={Math.round((0.7 - i * 0.09) * 100) / 100}
/>
))}
</g>
{/* the near bank: pines and a cabin */}
<path d={TREE_PATH} fill={wood} />
<rect x="0" y={HORIZON + 12} width="190" height="8" fill={wood} />
<rect x={SCENE_W - 210} y={HORIZON + 12} width="210" height="8" fill={wood} />
<g>
<path d={"M296 " + (HORIZON + 14) + "V" + (HORIZON - 18) + "L328 " + (HORIZON - 46) + "L360 " + (HORIZON - 18) + "V" + (HORIZON + 14) + "Z"} fill={night ? "#0c1430" : "#16223f"} />
<rect x="322" y={HORIZON - 16} width="12" height="14" rx="1.5" fill={night ? "#ffcf6b" : "#c9d3f2"} />
{night && <circle cx="328" cy={HORIZON - 9} r="17" fill="#ffcf6b" opacity="0.14" />}
<rect x="344" y={HORIZON - 58} width="7" height="20" fill={night ? "#0c1430" : "#16223f"} />
{!night && <ellipse cx="348" cy={HORIZON - 70} rx="9" ry="6" fill="#fff" opacity="0.35" />}
</g>
</svg>
);
}
/* ---- Component ------------------------------------------------------------ */
const EDGE = 12; // label inset from the frame
const FADE = 34; // px over which a label fades as the divider reaches it
export function ImageComparisonSlider({
before,
after,
initial = 50,
labelBefore = "Before",
labelAfter = "After",
orientation = "horizontal",
hover = false,
onChange,
className,
}: ImageComparisonSliderProps) {
const rootRef = useRef<HTMLDivElement>(null);
const gripRef = useRef<HTMLDivElement>(null);
const labelBeforeRef = useRef<HTMLSpanElement>(null);
const labelAfterRef = useRef<HTMLSpanElement>(null);
const start = clamp(Number.isFinite(initial) ? initial : 50, 0, 100);
const vertical = orientation === "vertical";
// Everything the loop and the handlers read lives in refs, synced in effects.
const sim = useRef({ cur: start, vel: 0, target: start, drag: false, offset: 0, wake: () => {}, userSet: (p: number) => void p });
const live = useRef({ vertical, hover, label: labelBefore + "|" + labelAfter });
const touch = useRef<{ id: number; x: number; y: number; started: boolean } | null>(null);
const changeRef = useRef(onChange);
useEffect(() => {
changeRef.current = onChange;
live.current = { vertical, hover, label: labelBefore + "|" + labelAfter };
sim.current.wake();
}, [onChange, vertical, hover, labelBefore, labelAfter]);
// A new `initial` repositions the divider; first run is a no-op.
useEffect(() => {
const s = sim.current;
s.target = start;
s.cur = start;
s.vel = 0;
s.wake();
}, [start]);
useEffect(() => {
const root = rootRef.current;
const grip = gripRef.current;
if (!root || !grip) return;
const s = sim.current;
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
let raf = 0;
let last = -1;
let lastAria = -1;
const size = { w: 0, h: 0, lw: 0, lh: 0, aw: 0, ah: 0 };
const measure = () => {
size.w = root.offsetWidth;
size.h = root.offsetHeight;
size.lw = labelBeforeRef.current?.offsetWidth ?? 0;
size.lh = labelBeforeRef.current?.offsetHeight ?? 0;
size.aw = labelAfterRef.current?.offsetWidth ?? 0;
size.ah = labelAfterRef.current?.offsetHeight ?? 0;
};
const write = () => {
const c = clamp(s.cur, 0, 100);
root.style.setProperty("--icp", c.toFixed(3));
const v = live.current.vertical;
const at = (c / 100) * (v ? size.h : size.w);
const span = v ? size.h : size.w;
const ob = v ? at - (EDGE + size.lh) : at - (EDGE + size.lw);
const oa = v ? span - EDGE - size.ah - at : span - EDGE - size.aw - at;
root.style.setProperty("--icb", clamp(ob / FADE, 0, 1).toFixed(3));
root.style.setProperty("--ica", clamp(oa / FADE, 0, 1).toFixed(3));
const r = Math.round(clamp(s.target, 0, 100));
if (r !== lastAria) {
lastAria = r;
const [lb, la] = live.current.label.split("|");
grip.setAttribute("aria-valuenow", String(r));
grip.setAttribute("aria-valuetext", r + "% " + (lb ?? "Before") + ", " + (100 - r) + "% " + (la ?? "After"));
}
};
const frame = (t: number) => {
raf = 0;
const dt = last < 0 ? 1 / 60 : Math.min(0.05, (t - last) / 1000);
last = t;
// Stiff while held (tracks the pointer), soft when it jumps or glides.
const k = s.drag ? 1100 : 240;
const c = s.drag ? 66 : 26;
const n = Math.max(1, Math.ceil(dt / (1 / 240)));
const h = dt / n;
for (let i = 0; i < n; i++) {
s.vel += (-k * (s.cur - s.target) - c * s.vel) * h;
s.cur += s.vel * h;
}
if (Math.abs(s.cur - s.target) < 0.012 && Math.abs(s.vel) < 0.05) {
s.cur = s.target;
s.vel = 0;
last = -1;
write();
return;
}
write();
raf = requestAnimationFrame(frame);
};
s.wake = () => {
measure();
if (motion.matches) {
s.cur = s.target;
s.vel = 0;
write();
return;
}
if (!raf) raf = requestAnimationFrame(frame);
};
s.userSet = (p: number) => {
const v = clamp(p, 0, 100);
s.target = v;
s.wake();
changeRef.current?.(Math.round(v * 10) / 10);
};
const ro = new ResizeObserver(() => {
measure();
write();
});
ro.observe(root);
if (labelBeforeRef.current) ro.observe(labelBeforeRef.current);
if (labelAfterRef.current) ro.observe(labelAfterRef.current);
measure();
write();
return () => {
ro.disconnect();
if (raf) cancelAnimationFrame(raf);
s.wake = () => {};
};
}, []);
const place = (e: ReactPointerEvent<HTMLDivElement>) => {
const root = rootRef.current;
if (!root) return;
const rect = root.getBoundingClientRect();
const span = live.current.vertical ? rect.height : rect.width;
if (span <= 0) return;
const at = live.current.vertical ? e.clientY - rect.top : e.clientX - rect.left;
sim.current.userSet((at / span) * 100 + sim.current.offset);
};
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, grabbed: boolean) => {
const root = rootRef.current;
if (!root) return;
const s = sim.current;
s.drag = true;
root.dataset.drag = "true";
try {
root.setPointerCapture(e.pointerId);
} catch {
/* the pointer is already gone */
}
gripRef.current?.focus({ preventScroll: true });
// Grabbing the grip keeps the offset so it never jumps under the finger.
const rect = root.getBoundingClientRect();
const span = live.current.vertical ? rect.height : rect.width;
const at = live.current.vertical ? e.clientY - rect.top : e.clientX - rect.left;
s.offset = grabbed && span > 0 ? s.cur - (at / span) * 100 : 0;
place(e);
};
const endDrag = () => {
const root = rootRef.current;
const s = sim.current;
s.drag = false;
s.offset = 0;
if (root) root.dataset.drag = "false";
s.wake();
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
const grabbed = !!gripRef.current && gripRef.current.contains(e.target as Node);
if (e.pointerType === "mouse") {
beginDrag(e, grabbed);
} else if (grabbed) {
touch.current = null;
beginDrag(e, true);
} else {
// Touch: wait to see whether this is a scroll, a tap or a drag.
touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, started: false };
}
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const s = sim.current;
const tp = touch.current;
if (tp && tp.id === e.pointerId && !tp.started) {
const dx = Math.abs(e.clientX - tp.x);
const dy = Math.abs(e.clientY - tp.y);
if (Math.max(dx, dy) < 6) return;
if ((live.current.vertical ? dy >= dx : dx >= dy)) {
tp.started = true;
beginDrag(e, false);
} else {
touch.current = null;
}
return;
}
if (s.drag) {
if (e.pointerType === "mouse" && e.buttons === 0) {
endDrag();
return;
}
place(e);
} else if (live.current.hover && e.pointerType === "mouse") {
place(e);
}
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
const tp = touch.current;
if (tp && tp.id === e.pointerId && !tp.started && e.type === "pointerup") {
sim.current.offset = 0;
place(e);
}
touch.current = null;
if (sim.current.drag) endDrag();
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const s = sim.current;
const step = e.shiftKey ? 10 : 2;
const back = live.current.vertical ? ["ArrowUp", "ArrowLeft"] : ["ArrowLeft", "ArrowDown"];
const fwd = live.current.vertical ? ["ArrowDown", "ArrowRight"] : ["ArrowRight", "ArrowUp"];
let next: number | null = null;
if (back.includes(e.key)) next = s.target - step;
else if (fwd.includes(e.key)) next = s.target + step;
else if (e.key === "PageUp") next = s.target - 10;
else if (e.key === "PageDown") next = s.target + 10;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = 100;
if (next === null) return;
e.preventDefault();
s.userSet(next);
};
const beforeSrc = safeSrc(before);
const afterSrc = safeSrc(after);
const root: CSSProperties & Record<string, string | number> = {
"--icp": start,
"--icb": 1,
"--ica": 1,
touchAction: vertical ? "pan-x" : "pan-y",
cursor: vertical ? "ns-resize" : "ew-resize",
WebkitTapHighlightColor: "transparent",
};
const clip = vertical ? "inset(0 0 calc(100% - var(--icp) * 1%) 0)" : "inset(0 calc(100% - var(--icp) * 1%) 0 0)";
const pill =
"pointer-events-none absolute z-[3] rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase leading-none tracking-[0.12em] text-white";
const pillStyle: CSSProperties = {
background: "rgba(8,10,14,0.5)",
boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.16)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
};
return (
<div
ref={rootRef}
data-drag="false"
className={
"group/ic relative isolate h-64 w-full max-w-xl select-none overflow-hidden rounded-2xl" + (className ? " " + className : "")
}
style={root}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{/* after: the base layer */}
<div className="pointer-events-none absolute inset-0">
{afterSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={afterSrc} alt={labelAfter} draggable={false} className="absolute inset-0 h-full w-full object-cover" />
) : (
<Scene mode="day" label={labelAfter} />
)}
</div>
{/* before: revealed up to the divider */}
<div className="pointer-events-none absolute inset-0" style={{ clipPath: clip, WebkitClipPath: clip }}>
{beforeSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={beforeSrc} alt={labelBefore} draggable={false} className="absolute inset-0 h-full w-full object-cover" />
) : (
<Scene mode="night" label={labelBefore} />
)}
</div>
{/* a soft shadow the divider casts onto the after side, and the line itself */}
<div
aria-hidden
className="pointer-events-none absolute z-[2]"
style={
vertical
? { left: 0, right: 0, top: "calc(var(--icp) * 1%)", height: 0 }
: { top: 0, bottom: 0, left: "calc(var(--icp) * 1%)", width: 0 }
}
>
<div
className="absolute"
style={
vertical
? { left: 0, right: 0, top: 0, height: 36, background: "linear-gradient(to bottom, rgba(0,0,0,0.22), transparent)" }
: { top: 0, bottom: 0, left: 0, width: 36, background: "linear-gradient(to right, rgba(0,0,0,0.22), transparent)" }
}
/>
<div
className="absolute"
style={
vertical
? {
left: 0,
right: 0,
top: -1,
height: 2,
background: "linear-gradient(to right, transparent, #fff 14%, #fff 86%, transparent)",
boxShadow: "0 0 12px rgba(255,255,255,0.55)",
}
: {
top: 0,
bottom: 0,
left: -1,
width: 2,
background: "linear-gradient(to bottom, transparent, #fff 14%, #fff 86%, transparent)",
boxShadow: "0 0 12px rgba(255,255,255,0.55)",
}
}
/>
</div>
<span ref={labelBeforeRef} className={pill} style={{ ...pillStyle, left: EDGE, [vertical ? "top" : "bottom"]: EDGE, opacity: "var(--icb)" }}>
{labelBefore}
</span>
<span
ref={labelAfterRef}
className={pill}
style={{ ...pillStyle, [vertical ? "left" : "right"]: EDGE, bottom: EDGE, opacity: "var(--ica)" }}
>
{labelAfter}
</span>
{/* the grip: clamped so it never leaves the frame */}
<div
className="absolute z-[4]"
style={
vertical
? { left: "50%", top: "clamp(24px, calc(var(--icp) * 1%), calc(100% - 24px))", transform: "translate(-50%, -50%)" }
: { top: "50%", left: "clamp(24px, calc(var(--icp) * 1%), calc(100% - 24px))", transform: "translate(-50%, -50%)" }
}
>
<div
ref={gripRef}
role="slider"
tabIndex={0}
aria-label="Comparison position"
aria-orientation={vertical ? "vertical" : "horizontal"}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(start)}
aria-valuetext={Math.round(start) + "% " + labelBefore + ", " + (100 - Math.round(start)) + "% " + labelAfter}
onKeyDown={onKeyDown}
className="flex h-11 w-11 items-center justify-center rounded-full text-white outline-none focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-white focus-visible:!rounded-full focus-visible:shadow-[0_0_0_6px_rgba(0,0,0,0.45)]"
style={{ cursor: vertical ? "ns-resize" : "ew-resize" }}
>
<span
aria-hidden
className="flex h-11 w-11 items-center justify-center rounded-full group-hover/ic:scale-105 group-data-[drag=true]/ic:!scale-90"
style={{
background: "rgba(255,255,255,0.16)",
backdropFilter: "blur(14px) saturate(1.4)",
WebkitBackdropFilter: "blur(14px) saturate(1.4)",
boxShadow:
"inset 0 0 0 1px rgba(255,255,255,0.55), inset 0 1px 0 rgba(255,255,255,0.4), 0 8px 24px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.12)",
transition: "scale 240ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)",
}}
>
<svg
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: vertical ? "rotate(90deg)" : undefined, filter: "drop-shadow(0 1px 2px rgba(0,0,0,0.35))" }}
>
<path
d="M11.5 10 6.5 15l5 5"
className="group-hover/ic:-translate-x-0.5 group-data-[drag=true]/ic:!-translate-x-1"
style={{ transition: "translate 240ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)" }}
/>
<path
d="M18.5 10l5 5-5 5"
className="group-hover/ic:translate-x-0.5 group-data-[drag=true]/ic:!translate-x-1"
style={{ transition: "translate 240ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)" }}
/>
</svg>
</span>
</div>
</div>
{/* hairline frame over the images, so a bright photo never bleeds into the page */}
<div aria-hidden className="pointer-events-none absolute inset-0 z-[5] rounded-2xl" style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.12)" }} />
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
A side-by-side only lets you compare two crops of attention; one shared frame with a draggable divider lets the eye compare the exact same pixels, which is why it shows up in every photo-editing and redesign case study. This is a from-scratch Pointer Events implementation with no dependencies. Both images fill the frame with object-fit cover, and the before layer is clipped by a single CSS variable, --icp (0 to 100), so the two stay perfectly registered at any size. One requestAnimationFrame loop runs a spring on that variable and writes it straight to the DOM, so a drag never re-renders React; the loop sleeps the moment the divider is at rest. Pressing anywhere in the frame glides the divider to that point on a soft spring; grabbing the glass grip drags without a jump; while held it switches to a stiff spring that tracks the pointer. The labels in the corners fade out as the divider reaches them, so they never sit underneath it. Set orientation to vertical for a top/bottom split, or hover to make a mouse drive the divider with no press (touch and pen still drag). On touch the frame waits to tell a tap, a drag and a page scroll apart, so scrolling past it never moves the divider. Pass your own before and after URLs; they are checked by a small safeSrc helper that accepts http(s) and relative URLs only. With no images the component draws its own night and day mountain lake in SVG, which is also what the preview shows. Both layers need the same aspect ratio; cover crops them identically.
Curator’s note
Rebuilt in October around one idea: the divider is a spring, not a coordinate. It glides to wherever you press, then follows your pointer on a stiffer spring while you drag.
