Component
Image compare
A slider with a draggable handle that reveals before and after content.
- #arc
- #component
- #image
- #compare
- #slider
- #before-after
- #drag
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/image-compare.jsonFiles
components/arc/image-compare/image-compare.module.csscomponents/arc/image-compare/image-compare.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownimage-compare.module.cssimage-compare.module.cssimage-compare.module.csscss
/* The frame is a size container, so the divider's line can span any angle with container units. Vertical pans still scroll the page on touch. */
.root { position: relative; display: block; width: 100%; overflow: clip; container-type: size; isolation: isolate; border-radius: var(--radius-panel); background: var(--surface-muted); cursor: ew-resize; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
.root[data-orientation="vertical"] { cursor: ns-resize; touch-action: pan-x; }
/* A hairline keeps light photos from bleeding into the page. */
.root::after { content: ""; position: absolute; z-index: 3; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--foreground) 8%, transparent); pointer-events: none; }
.layer { position: absolute; inset: 0; }
.layer :where(img, picture, video) { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; -webkit-user-drag: none; }
/* Captions sit on a frosted chip so they read over any photo, below the divider. */
.chip { position: absolute; z-index: 1; top: var(--space-3); left: var(--space-3); padding: 3px 10px; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--background) 74%, transparent); color: var(--foreground); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); pointer-events: none; -webkit-backdrop-filter: blur(12px) saturate(1.4); backdrop-filter: blur(12px) saturate(1.4); }
.chip[data-place="end"] { right: var(--space-3); left: auto; }
.chip[data-place="bottom"] { top: auto; bottom: var(--space-3); }
/* The pivot is a frame-sized layer moved by a percentage of itself; its top-left corner is the divider's center. */
.pivot { position: absolute; z-index: 2; inset: 0; pointer-events: none; }
.rotor { position: absolute; top: 0; left: 0; width: 0; height: 0; }
.line { position: absolute; top: calc((100cqw + 100cqh) * -1); left: -1px; width: 2px; height: calc((100cqw + 100cqh) * 2); background: var(--surface-raised); box-shadow: 0 0 0 .5px oklch(0% 0 0 / .14); }
/* The line sits on photography, not on the theme, so it stays light in dark mode too. */
:global(:root[data-theme="dark"]) .line { background: var(--foreground); }
.handleSlot { position: absolute; top: 0; left: 0; }
/* Centered with the translate property; Motion only animates its width and height, so the shape morphs without scaling the icons. */
.handle { position: absolute; top: 0; left: 0; display: grid; box-sizing: border-box; width: 40px; height: 40px; place-items: center; translate: -50% -50%; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: var(--shadow-raised); color: var(--foreground); pointer-events: auto; outline: 0 solid transparent; transition: border-color var(--duration-fast) var(--ease-standard); }
.handle::after { content: ""; position: absolute; inset: -8px; border-radius: inherit; }
@media (hover: hover) and (pointer: fine) { .root:hover .handle { border-color: var(--border-strong); } }
.chevrons { display: flex; align-items: center; }
.chevron { display: grid; margin-inline: -2.5px; place-items: center; }
@media (prefers-reduced-motion: reduce) { .handle { transition: none; } }
image-compare.tsximage-compare.tsximage-compare.tsxtsx
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./image-compare.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
export interface ImageCompareProps {
/** The original, shown on the left (or top). Pass an image with its own alt text; it is sized to cover the frame. */
before: ReactNode;
/** The result, shown on the right (or bottom). */
after: ReactNode;
/** Where the divider sits, in percent from the left (or top). */
position?: number;
defaultPosition?: number;
onPositionChange?: (position: number) => void;
/** Vertical stacks the images top and bottom. Changing it swings the divider a quarter turn instead of swapping layouts. */
orientation?: "horizontal" | "vertical";
/** Captions over each side. They fade as the divider reaches them. Pass false to hide them. */
labels?: [string, string] | false;
/** Accessible name of the divider. */
label?: string;
/** Frame proportions, as a CSS aspect-ratio. */
aspectRatio?: string;
className?: string;
}
type Metrics = { w: number; h: number; bw: number; bh: number; aw: number; ah: number };
type Drag = { pointer: number; grab: number; press: boolean; started: boolean; x: number; y: number; raw: number };
const { spring } = motionTokens;
/** Motion drops velocity on time-defined springs, so hand-offs run the same springs written as stiffness and damping, settling far below a pixel. */
const physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: "spring" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta: .002, restSpeed: .02 }; };
const moveSpring = physical(spring.snappy);
const turnSpring = physical(spring.morph);
const kickSpring = physical(spring.morph);
/** Rubber-band travel past an edge, the handle's inset from the frame, how far it gives inside that inset, and a key press at a limit. */
const STRETCH = 9, MARGIN = 42, GIVE = 12, BUMP_PX = 150, INSET = 12;
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const lerp = (from: number, to: number, amount: number) => from + (to - from) * amount;
/** iOS style resistance: travel past a limit gives less and less, and never more than `limit` pixels. */
const rubber = (distance: number, limit = STRETCH) => Math.sign(distance) * (1 - 1 / (Math.abs(distance) * .55 / limit + 1)) * limit;
/** The handle keeps the line's pace in the middle and slows smoothly near an edge, so it is never cut off and never stops dead. */
const give = (distance: number) => GIVE * (1 - 1 / (distance / GIVE + 1));
const soften = (at: number, size: number) => at < MARGIN ? MARGIN - give(MARGIN - at) : at > size - MARGIN ? size - MARGIN + give(at - size + MARGIN) : at;
const fade = (room: number) => clamp(room / 28, 0, 1);
/** The before side of a line through `center` at `angle`, cut from the frame: the clip while the divider turns. */
function halfPlane(w: number, h: number, center: [number, number], angle: number) {
const nx = -Math.cos(angle), ny = -Math.sin(angle);
const side = ([x, y]: [number, number]) => (x - center[0]) * nx + (y - center[1]) * ny;
const corners: [number, number][] = [[0, 0], [w, 0], [w, h], [0, h]];
const points: [number, number][] = [];
corners.forEach((corner, index) => {
const next = corners[(index + 1) % 4]!, a = side(corner), b = side(next);
if (a >= 0) points.push(corner);
if ((a >= 0) !== (b >= 0)) { const t = a / (a - b); points.push([corner[0] + (next[0] - corner[0]) * t, corner[1] + (next[1] - corner[1]) * t]); }
});
return points.length ? `polygon(${points.map(([x, y]) => `${x.toFixed(2)}px ${y.toFixed(2)}px`).join(", ")})` : "inset(50%)";
}
/**
* Before and after, split by a divider. Drag anywhere to move it: the handle follows 1:1, rubber-bands past the edges, and stays where it
* is released. A press on the image springs the divider there; on touch the press waits to see a sideways drag so the page still scrolls.
* Arrow keys step 1% (Shift for 10%), Home and End reveal one side fully, and a double-click on the handle springs it back to the middle.
*/
export function ImageCompare({ before, after, position, defaultPosition = 50, onPositionChange, orientation = "horizontal", labels = ["전", "후"], label = "전후 비교", aspectRatio = "3 / 2", className }: ImageCompareProps) {
const reduced = useReducedMotion();
const vertical = orientation === "vertical";
const [internal, setInternal] = useState(defaultPosition);
const current = clamp(position ?? internal, 0, 100);
const afterShare = Math.round(100 - current);
const rootRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const beforeChip = useRef<HTMLSpanElement>(null);
const afterChip = useRef<HTMLSpanElement>(null);
const latest = useRef(current);
const goal = useRef(current);
const drag = useRef<Drag | null>(null);
const pressedHandle = useRef(false);
const [dragging, setDragging] = useState(false);
const [quiet, setQuiet] = useState(false);
// The divider is a committed position plus a catch-up offset: a press springs the offset to zero while the pointer moves the position
// 1:1, so the divider glides to the finger and never trails it.
const pos = useMotionValue(current), lag = useMotionValue(0);
const theta = useMotionValue(vertical ? 90 : 0);
const metrics = useMotionValue<Metrics>({ w: 0, h: 0, bw: 0, bh: 0, aw: 0, ah: 0 });
const shown = useTransform(() => pos.get() + lag.get());
const turn = useTransform(() => clamp(theta.get() / 90, 0, 1));
/** The divider's pivot in pixels, blending from the horizontal to the vertical layout as it turns. */
const center = (p: number, m: Metrics): [number, number] => [lerp((p / 100) * m.w, m.w / 2, turn.get()), lerp(m.h / 2, (p / 100) * m.h, turn.get())];
// At rest every style is a percentage, so the server renders it and a resize never lags; only a turn in progress uses pixels.
const clipPath = useTransform(() => {
const angle = theta.get(), p = clamp(shown.get(), 0, 100), m = metrics.get();
if (angle === 0 || !m.w) return angle >= 45 ? `inset(0 0 ${(100 - p).toFixed(3)}% 0)` : `inset(0 ${(100 - p).toFixed(3)}% 0 0)`;
if (angle === 90) return `inset(0 0 ${(100 - p).toFixed(3)}% 0)`;
return halfPlane(m.w, m.h, center(p, m), (angle * Math.PI) / 180);
});
const rest = (angle: number, m: Metrics) => angle === 0 || angle === 90 || !m.w;
const pivotX = useTransform(() => { const angle = theta.get(), m = metrics.get(); return rest(angle, m) ? (angle >= 45 ? "50%" : `${shown.get()}%`) : `${center(shown.get(), m)[0]}px`; });
const pivotY = useTransform(() => { const angle = theta.get(), m = metrics.get(); return rest(angle, m) ? (angle >= 45 ? `${shown.get()}%` : "50%") : `${center(shown.get(), m)[1]}px`; });
/** How far the handle sits from the line along the drag axis, so it stays whole near an edge. */
const offset = () => { const m = metrics.get(); if (!m.w) return 0; const p = shown.get(), x = (p / 100) * m.w, y = (p / 100) * m.h; return lerp(soften(x, m.w) - x, soften(y, m.h) - y, turn.get()); };
const handleX = useTransform(() => offset() * Math.cos((theta.get() * Math.PI) / 180));
const handleY = useTransform(() => offset() * Math.sin((theta.get() * Math.PI) / 180));
// Captions fade as the divider reaches them, and hand over mid-turn when the after caption changes corner.
const beforeOpacity = useTransform(() => {
const m = metrics.get(), k = turn.get(), p = clamp(shown.get(), 0, 100), across = clamp(1 - k * 3, 0, 1), down = clamp(k * 3 - 2, 0, 1);
if (!m.w) return across + down;
return across * fade((p / 100) * m.w - (INSET + m.bw + 8)) + down * fade((p / 100) * m.h - (INSET + m.bh + 8));
});
const afterAcross = useTransform(() => { const m = metrics.get(), k = clamp(1 - turn.get() * 3, 0, 1); return m.w ? k * fade(m.w - INSET - m.aw - 8 - (clamp(shown.get(), 0, 100) / 100) * m.w) : k; });
const afterDown = useTransform(() => { const m = metrics.get(), k = clamp(turn.get() * 3 - 2, 0, 1); return m.w ? k * fade(m.h - INSET - m.ah - 8 - (clamp(shown.get(), 0, 100) / 100) * m.h) : k; });
useLayoutEffect(() => {
const root = rootRef.current;
if (!root || typeof ResizeObserver === "undefined") return;
const measure = () => metrics.set({ w: root.clientWidth, h: root.clientHeight, bw: beforeChip.current?.offsetWidth ?? 0, bh: beforeChip.current?.offsetHeight ?? 0, aw: afterChip.current?.offsetWidth ?? 0, ah: afterChip.current?.offsetHeight ?? 0 });
const observer = new ResizeObserver(measure);
[root, beforeChip.current, afterChip.current].forEach(node => node && observer.observe(node));
measure();
return () => observer.disconnect();
}, [metrics]);
// A new orientation swings the divider a quarter turn about its pivot while the clip follows it.
useEffect(() => {
const target = vertical ? 90 : 0;
if (theta.get() === target) return;
if (reduced) theta.jump(target); else animate(theta, target, turnSpring);
}, [reduced, theta, vertical]);
// Positions that arrive from outside a drag (props, keys, a double-click) spring the divider there.
useLayoutEffect(() => {
latest.current = current;
if (drag.current?.started || goal.current === current) return;
goal.current = current;
const from = pos.get() + lag.get();
lag.jump(0);
pos.jump(from);
if (reduced) pos.jump(current); else animate(pos, current, moveSpring);
});
function commit(next: number, live = false) {
if (live && Math.round(next) === Math.round(latest.current)) return;
const value = Number(next.toFixed(2));
if (value === latest.current) return;
latest.current = value;
if (position === undefined) setInternal(value);
onPositionChange?.(value);
}
const percentAt = (event: PointerEvent<HTMLDivElement>) => {
const rect = rootRef.current!.getBoundingClientRect();
return vertical ? ((event.clientY - rect.top) / (rect.height || 1)) * 100 : ((event.clientX - rect.left) / (rect.width || 1)) * 100;
};
const axisSize = () => { const rect = rootRef.current?.getBoundingClientRect(); return (vertical ? rect?.height : rect?.width) || 1; };
/** Focus that follows a pointer keeps the handle quiet: no ring until a key is pressed. */
function focusQuietly() {
const node = handleRef.current;
if (!node || document.activeElement === node) return;
setQuiet(true);
node.focus({ preventScroll: true });
}
function start(state: Drag, at: number) {
const from = pos.get() + lag.get();
state.started = true;
state.grab = state.press ? 0 : at - from;
lag.jump(0);
pos.jump(from);
setDragging(true);
focusQuietly();
follow(state, at, true);
}
/** Puts the line under the pointer, with resistance past the edges, and commits the position once per whole percent. */
function follow(state: Drag, at: number, first = false) {
const raw = at - state.grab, size = axisSize();
const edge = clamp(raw, 0, 100);
const placed = reduced ? edge : edge + (rubber(((raw - edge) / 100) * size) / size) * 100;
state.raw = raw;
if (first && state.press && !reduced) {
const from = pos.get();
pos.jump(placed);
lag.jump(from - placed);
animate(lag, 0, moveSpring);
} else pos.set(placed);
commit(edge, true);
}
function onPointerDown(event: PointerEvent<HTMLDivElement>) {
if (event.button !== 0 || !event.isPrimary || !rootRef.current) return;
const onHandle = !!(event.target as HTMLElement).closest("[data-handle]");
pressedHandle.current = onHandle;
if (onHandle) playUiSound("tick");
event.currentTarget.setPointerCapture(event.pointerId);
const state: Drag = { pointer: event.pointerId, grab: 0, press: !onHandle, started: false, x: event.clientX, y: event.clientY, raw: 0 };
drag.current = state;
// A finger on the photo might be scrolling the page, so it waits for a sideways drag; a mouse or the handle answers at once.
if (event.pointerType !== "touch" || onHandle) start(state, percentAt(event));
}
function onPointerMove(event: PointerEvent<HTMLDivElement>) {
const state = drag.current;
if (!state || state.pointer !== event.pointerId) return;
if (!state.started) {
const along = Math.abs(vertical ? event.clientY - state.y : event.clientX - state.x), across = Math.abs(vertical ? event.clientX - state.x : event.clientY - state.y);
if (along < 6 && across < 6) return;
if (across > along) { drag.current = null; return; }
start(state, percentAt(event));
return;
}
follow(state, percentAt(event));
}
function onPointerEnd(event: PointerEvent<HTMLDivElement>) {
const state = drag.current;
if (!state || state.pointer !== event.pointerId) return;
drag.current = null;
// A tap on the photo moves the divider there.
if (!state.started) { if (event.type === "pointerup") { focusQuietly(); commit(clamp(percentAt(event), 0, 100)); } return; }
// The divider stays where it was let go. Past an edge, or still catching up to a press, it springs on with the speed it had.
const from = pos.get() + lag.get(), velocity = lag.getVelocity() + (from > 100 || from < 0 ? pos.getVelocity() : 0);
const target = clamp(lag.get() !== 0 ? state.raw : from, 0, 100);
lag.jump(0);
pos.jump(from);
goal.current = Number(target.toFixed(2));
if (from !== target) { if (reduced) pos.jump(target); else animate(pos, target, { ...moveSpring, velocity }); }
commit(target);
setDragging(false);
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const large = event.shiftKey ? 10 : 1;
// Keys move the divider the way they point along its axis; the other two follow the value, where up and right mean more of the after image.
const toward: Record<string, number> = vertical ? { ArrowUp: -large, ArrowDown: large, ArrowRight: -large, ArrowLeft: large, PageUp: -10, PageDown: 10 } : { ArrowLeft: -large, ArrowRight: large, ArrowUp: -large, ArrowDown: large, PageUp: -10, PageDown: 10 };
let next: number;
if (event.key === "Home") next = 100;
else if (event.key === "End") next = 0;
else if (event.key in toward) next = Math.round(latest.current) + toward[event.key]!;
else return;
event.preventDefault();
setQuiet(false);
const clamped = clamp(next, 0, 100);
if (clamped === latest.current) {
if (!reduced && next !== clamped) animate(pos, goal.current, { ...kickSpring, velocity: (Math.sign(next - clamped) * BUMP_PX / axisSize()) * 100 });
return;
}
playUiSound("tick");
commit(clamped);
}
const capsule = dragging ? (vertical ? { width: 36, height: 56 } : { width: 56, height: 36 }) : { width: 40, height: 40 };
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-orientation={orientation} data-dragging={dragging || undefined} data-frame-keep="" style={{ aspectRatio }}
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd} onLostPointerCapture={onPointerEnd} onMouseDown={event => event.preventDefault()} onDragStart={event => event.preventDefault()}
// Pointer capture sends the double-click to the frame, so it checks where the presses began.
onDoubleClick={() => { if (pressedHandle.current && latest.current !== 50) commit(50); }}>
<div className={styles.layer}>{after}</div>
<motion.div className={styles.layer} style={{ clipPath }}>{before}</motion.div>
{labels && <>
<motion.span ref={beforeChip} className={styles.chip} style={{ opacity: beforeOpacity }} aria-hidden="true">{labels[0]}</motion.span>
<motion.span ref={afterChip} className={styles.chip} data-place="end" style={{ opacity: afterAcross }} aria-hidden="true">{labels[1]}</motion.span>
<motion.span className={styles.chip} data-place="bottom" style={{ opacity: afterDown }} aria-hidden="true">{labels[1]}</motion.span>
</>}
<motion.div className={styles.pivot} style={{ x: pivotX, y: pivotY }}>
<motion.span className={styles.rotor} style={{ rotate: theta }}><span className={styles.line} /></motion.span>
<motion.span className={styles.handleSlot} style={{ x: handleX, y: handleY }}>
<motion.div ref={handleRef} data-handle="" data-quiet={quiet || undefined} className={styles.handle} role="slider" tabIndex={0} aria-label={label} aria-orientation={orientation}
aria-valuemin={0} aria-valuemax={100} aria-valuenow={afterShare} aria-valuetext={`후 이미지 ${afterShare}% 보임`}
initial={false} animate={capsule} transition={reduced ? { duration: 0 } : spring.morph} onKeyDown={onKeyDown} onBlur={() => setQuiet(false)}>
<motion.span className={styles.chevrons} style={{ rotate: theta }} aria-hidden="true">
<motion.span className={styles.chevron} initial={false} animate={{ x: dragging ? -4 : 0 }} transition={reduced ? { duration: 0 } : spring.morph}><UiIcon iconRole="chevron-left" size={16} strokeWidth={1.75} /></motion.span>
<motion.span className={styles.chevron} initial={false} animate={{ x: dragging ? 4 : 0 }} transition={reduced ? { duration: 0 } : spring.morph}><UiIcon iconRole="chevron-right" size={16} strokeWidth={1.75} /></motion.span>
</motion.span>
</motion.div>
</motion.span>
</motion.div>
</div>;
}
export default ImageCompare;