"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
import { ChevronsLeftRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ImageCompareProps {
/** Left / "before" layer. Any node: SVG, HTML mockup, <img>… */
before: React.ReactNode;
/** Right / "after" layer. */
after: React.ReactNode;
beforeLabel?: string;
afterLabel?: string;
/** Start position in percent. */
initial?: number;
/** Keyboard step in percent (Shift = ×5). */
step?: number;
/** Play a small left-right nudge the first time it mounts, hinting that it can be dragged. */
hint?: boolean;
/** Accessible name for the slider. */
label?: string;
onChange?: (value: number) => void;
className?: string;
}
const clamp = (v: number) => Math.min(100, Math.max(0, v));
export function ImageCompare({
before,
after,
beforeLabel = "Before",
afterLabel = "After",
initial = 50,
step = 2,
hint = true,
label = "Comparison position",
onChange,
className,
}: ImageCompareProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
const pos = useMotionValue(clamp(initial));
const [value, setValue] = React.useState(clamp(initial));
const [dragging, setDragging] = React.useState(false);
useMotionValueEvent(pos, "change", (v) => {
const rounded = Math.round(v);
setValue((prev) => (prev === rounded ? prev : rounded));
onChange?.(v);
});
const clip = useTransform(pos, (v) => `inset(0 ${100 - v}% 0 0)`);
const left = useTransform(pos, (v) => `${v}%`);
const beforeTagOpacity = useTransform(pos, [0, 18], [0, 1]);
const afterTagOpacity = useTransform(pos, [82, 100], [1, 0]);
React.useEffect(() => {
if (!hint || reduce) return;
const start = pos.get();
const controls = animate(pos, [start, start + 12, start - 10, start], { duration: 1.6, delay: 0.6, ease: "easeInOut" });
return () => controls.stop();
}, [hint, reduce, pos]);
const fromClientX = (clientX: number) => {
const r = ref.current?.getBoundingClientRect();
if (!r) return pos.get();
return clamp(((clientX - r.left) / r.width) * 100);
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
pos.stop();
// Tap to jump (animated), drag to follow.
animate(pos, fromClientX(e.clientX), reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 40 });
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
pos.stop();
pos.set(fromClientX(e.clientX));
};
const end = () => setDragging(false);
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const s = e.shiftKey ? step * 5 : step;
const map: Record<string, number> = {
ArrowLeft: pos.get() - s,
ArrowDown: pos.get() - s,
ArrowRight: pos.get() + s,
ArrowUp: pos.get() + s,
PageDown: pos.get() - 10,
PageUp: pos.get() + 10,
Home: 0,
End: 100,
};
if (!(e.key in map)) return;
e.preventDefault();
animate(pos, clamp(map[e.key]), reduce ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 45 });
};
return (
<div
ref={ref}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={end}
onPointerCancel={end}
className={cn(
"relative w-full select-none overflow-hidden rounded-2xl border bg-muted [touch-action:pan-y]",
dragging ? "cursor-grabbing" : "cursor-ew-resize",
className,
)}
>
<div className="relative h-full w-full">{after}</div>
<motion.div aria-hidden={value < 1} className="absolute inset-0" style={{ clipPath: clip }}>
{before}
</motion.div>
<motion.span
style={{ opacity: beforeTagOpacity }}
className="pointer-events-none absolute bottom-3 left-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur"
>
{beforeLabel}
</motion.span>
<motion.span
style={{ opacity: afterTagOpacity }}
className="pointer-events-none absolute bottom-3 right-3 rounded-full bg-black/60 px-2.5 py-1 text-xs font-medium text-white backdrop-blur"
>
{afterLabel}
</motion.span>
<motion.div className="pointer-events-none absolute inset-y-0 -ml-px w-0.5 bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.12),0_0_12px_rgba(0,0,0,0.35)]" style={{ left }}>
<div
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
aria-valuetext={`${value}% ${beforeLabel.toLowerCase()}`}
onKeyDown={onKeyDown}
className={cn(
"pointer-events-auto absolute left-1/2 top-1/2 grid size-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-black/10 bg-white text-slate-900 shadow-lg outline-none transition-transform",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
dragging && "scale-110",
)}
>
<ChevronsLeftRight className="size-5" />
</div>
</motion.div>
</div>
);
}