Image compare
Drag the divider to wipe between two pictures. Fling it and it coasts on, bouncing softly off the edges.
Code
1. Install
npm i framer-motion
2. Files
"use client";
// Compare: drag the divider to wipe between two pictures. Fling it and it coasts on, bouncing softly off the edges.
// You pull the divider by its middle, so its ends trail on a spring and the line bows like a string, then wobbles straight.
// While you drag, the position is written straight to a CSS variable; React only hears about it when things settle.
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useMotionValue, useSpring, useTransform, type MotionStyle } from "framer-motion";
export type CompareCue = "grab" | "place";
export type ComparePicture = {
/** Image URL. */
src: string;
alt: string;
};
export type CompareProps = {
/** Picture left of the divider. The default points at Teseria's demo art (`/art/*.svg`); pass your own. */
before?: ComparePicture;
/** Picture right of the divider. The default points at Teseria's demo art; pass your own. */
after?: ComparePicture;
/** CSS filter applied to the "before" picture, so one image can stand in for both. */
beforeFilter?: string;
/** The two corner tags. */
labels?: [string, string];
/** Accessible name of the slider. */
label?: string;
/** Controlled divider position, 0-1. */
value?: number;
/** Uncontrolled initial position, 0-1. */
defaultValue?: number;
/** Called with the position when a drag, coast or key press settles. */
onChange?: (value: number) => void;
/** Width in px. */
width?: number;
/** Height in px. */
height?: number;
/** Corner radius in px. */
corner?: number;
/** Sound hook: "grab" on grab, "place" on release. */
onSound?: (cue: CompareCue) => void;
className?: string;
style?: CSSProperties;
};
const DEMO = "/art/mountain.svg";
const FRICTION = 0.93; // per 1/60s frame
const WALL_KEEP = 0.3; // share of speed kept after hitting an edge
const MAX_BOW = 0.035; // furthest the ends may trail the knob, as a share of the width
const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
export function Compare({
before = { src: DEMO, alt: "Mountain, before" },
after = { src: DEMO, alt: "Mountain, after" },
beforeFilter = "grayscale(1) contrast(.9)",
labels = ["Before", "After"],
label = "Compare",
value,
defaultValue = 0.5,
onChange,
width = 280,
height = 180,
corner = 16,
onSound,
className,
style,
}: CompareProps) {
const box = useRef<HTMLDivElement>(null);
const pos = useMotionValue(value ?? defaultValue);
const [settled, setSettled] = useState(value ?? defaultValue);
const ends = useSpring(pos, { stiffness: 1400, damping: 80, mass: 0.6 });
// the line through the knob at mid-height: ends at x - lag, and a control point at x + lag puts the curve's middle on x
const curve = (k: number, e: number) => {
const x = k * width, lag = Math.min(MAX_BOW, Math.max(-MAX_BOW, k - e)) * width;
return `${x - lag} 0Q${x + lag} ${height / 2} ${x - lag} ${height}`;
};
const line = useTransform([pos, ends], ([k, e]: number[]) => `M${curve(k, e)}`);
const clip = useTransform([pos, ends], ([k, e]: number[]) => `path("M0 0L${curve(k, e)}L0 ${height}Z")`);
const report = useRef(onChange);
report.current = onChange;
// one gesture at a time: whether the pointer is down, its speed (per frame), and the last sample's time
const g = useRef({ down: false, speed: 0, at: 0, frame: 0 });
const land = () => {
const v = pos.get();
setSettled(v);
report.current?.(v);
};
const toUnits = (clientX: number) => {
const r = box.current?.getBoundingClientRect();
return r && r.width ? clamp01((clientX - r.left) / r.width) : pos.get();
};
const glide = () => {
cancelAnimationFrame(g.current.frame);
let prev = 0;
const tick = (now: number) => {
const frames = prev ? Math.min(2.5, Math.max(0, (now - prev) / 16.67)) : 1;
prev = now;
g.current.speed *= FRICTION ** frames;
let next = pos.get() + g.current.speed * frames;
if (next < 0 || next > 1) {
next = clamp01(next);
g.current.speed *= -WALL_KEEP;
}
pos.set(next);
if (Math.abs(g.current.speed) > 0.0004) g.current.frame = requestAnimationFrame(tick);
else land();
};
g.current.frame = requestAnimationFrame(tick);
};
useEffect(() => () => cancelAnimationFrame(g.current.frame), []);
useEffect(() => {
if (value === undefined || g.current.down) return;
cancelAnimationFrame(g.current.frame);
pos.set(value);
setSettled(value);
}, [value, pos]);
const grab = (ev: PointerEvent<HTMLDivElement>) => {
try {
ev.currentTarget.setPointerCapture(ev.pointerId);
} catch {
// pointer already gone; dragging still works
}
cancelAnimationFrame(g.current.frame);
Object.assign(g.current, { down: true, speed: 0, at: performance.now() });
onSound?.("grab");
pos.set(toUnits(ev.clientX));
};
const drag = (ev: PointerEvent<HTMLDivElement>) => {
if (!g.current.down) return;
const now = performance.now();
const to = toUnits(ev.clientX);
const frames = Math.max(1, now - g.current.at) / 16.67;
g.current.speed = (g.current.speed + (to - pos.get()) / frames) / 2;
g.current.at = now;
pos.set(to);
};
const release = () => {
if (!g.current.down) return;
g.current.down = false;
if (performance.now() - g.current.at > 80) g.current.speed = 0; // paused before letting go: no fling
onSound?.("place");
if (Math.abs(g.current.speed) > 0.002) glide();
else land();
};
const nudge = (ev: KeyboardEvent<HTMLDivElement>) => {
const step = ({ ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 } as Record<string, number>)[ev.key];
const v = pos.get();
const next = step ? v + step * (ev.shiftKey ? 0.25 : 0.05) : ev.key === "PageUp" ? v + 0.25 : ev.key === "PageDown" ? v - 0.25 : ev.key === "Home" ? 0 : ev.key === "End" ? 1 : null;
if (next === null) return;
ev.preventDefault();
cancelAnimationFrame(g.current.frame);
pos.set(clamp01(next));
land();
};
return (
<motion.div
ref={box}
className={["tcp-root", className].filter(Boolean).join(" ")}
style={{ width, height, borderRadius: corner, "--tcp-p": pos, ...style } as MotionStyle}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(settled * 100)}
onPointerDown={grab}
onPointerMove={drag}
onPointerUp={release}
onPointerCancel={() => void (g.current.down = false)}
onKeyDown={nudge}
>
<div className="tcp-after">
<img src={after.src} alt={after.alt} draggable={false} />
</div>
<motion.div className="tcp-before" style={{ clipPath: clip }}>
<img src={before.src} alt={before.alt} draggable={false} style={{ filter: beforeFilter }} />
</motion.div>
<span className="tcp-tag tcp-l">{labels[0]}</span>
<span className="tcp-tag tcp-r">{labels[1]}</span>
<svg className="tcp-line" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" aria-hidden>
<motion.path d={line} />
</svg>
<span className="tcp-knob" aria-hidden>
<svg viewBox="0 0 16 16" width="14" height="14">
<path d="M6 3 2 8l4 5M10 3l4 5-4 5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</motion.div>
);
}
.tcp-root,
.tcp-root * {
box-sizing: border-box;
}
.tcp-root {
--tcp-p: 0.5;
position: relative;
overflow: hidden;
cursor: col-resize;
touch-action: none;
user-select: none;
box-shadow: var(--ts-edge);
-webkit-tap-highlight-color: transparent;
}
.tcp-root:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
.tcp-after,
.tcp-before {
position: absolute;
inset: 0;
}
.tcp-after > *,
.tcp-before > * {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tcp-tag {
position: absolute;
top: 10px;
padding: 4px 9px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
font-weight: 500;
backdrop-filter: blur(6px);
pointer-events: none;
transition: opacity 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcp-l {
left: 10px;
opacity: clamp(0, calc(var(--tcp-p) * 4 - 0.4), 1);
}
.tcp-r {
right: 10px;
opacity: clamp(0, calc((1 - var(--tcp-p)) * 4 - 0.4), 1);
}
.tcp-line {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.tcp-line path {
fill: none;
stroke: #fff;
stroke-width: 2;
filter: drop-shadow(0 0 0.5px rgba(0, 0, 0, 0.3));
}
.tcp-knob {
position: absolute;
top: 50%;
left: calc(var(--tcp-p) * 100%);
width: 30px;
height: 30px;
translate: -50% -50%;
border-radius: 50%;
background: #fff;
color: #161514;
display: grid;
place-items: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1);
}
.tcp-root:active .tcp-knob {
scale: 0.92;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Compare } from "./Compare";
import "./Compare.css";
export default function Demo() {
return <Compare />;
}