"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { SPRING_GLIDE, SPRING_PRESS } from "@/lib/ease";
import {
capturePointer,
isHoveringPointer,
releasePointer,
TOUCH_GESTURE_CLASS,
} from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface ImageCompareProps {
/** Left / underneath layer. An image URL or any node. */
before: ReactNode | string;
/** Right / clipped top layer. An image URL or any node. */
after: ReactNode | string;
/** Alt text when `before` is a URL. */
beforeAlt?: string;
/** Alt text when `after` is a URL. */
afterAlt?: string;
/** Divider position in percent (controlled). */
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Divider follows the pointer while hovering instead of needing a drag. */
hover?: boolean;
beforeLabel?: ReactNode;
afterLabel?: ReactNode;
showLabels?: boolean;
"aria-label"?: string;
className?: string;
}
const KEY_STEP = 1;
const KEY_STEP_LARGE = 10;
const clamp = (v: number) => Math.min(100, Math.max(0, v));
function Layer({ content, alt }: { content: ReactNode | string; alt?: string }) {
if (typeof content === "string") {
return (
// biome-ignore lint/performance/noImgElement: keeps the component framework-agnostic and both layers pixel-aligned.
);
}
return
{content}
;
}
export function ImageCompare({
before,
after,
beforeAlt,
afterAlt,
value,
defaultValue = 50,
onValueChange,
hover = false,
beforeLabel = "Before",
afterLabel = "After",
showLabels = true,
"aria-label": ariaLabel = "Comparison divider",
className,
}: ImageCompareProps) {
const reduce = useReducedMotion();
const stageRef = useRef(null);
const handleRef = useRef(null);
const [internal, setInternal] = useState(clamp(defaultValue));
const controlled = value !== undefined;
const percent = clamp(controlled ? value : internal);
const [dragging, setDragging] = useState(false);
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
// Clip the top ("after") layer from the left edge up to the divider.
const clipPath = useMotionTemplate`inset(0 0 0 ${pos}%)`;
const left = useMotionTemplate`${pos}%`;
// Chips fade as their side shrinks toward the edge.
const beforeOpacity = useTransform(pos, [4, 18], [0, 1]);
const afterOpacity = useTransform(pos, [82, 96], [1, 0]);
const setValue = useCallback(
(next: number) => {
const v = Math.round(clamp(next) * 10) / 10;
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[controlled, onValueChange],
);
const fromPointer = (clientX: number) => {
const rect = stageRef.current?.getBoundingClientRect();
if (!rect?.width) return;
setValue(((clientX - rect.left) / rect.width) * 100);
};
const onPointerDown = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
capturePointer(event.currentTarget, event.pointerId);
handleRef.current?.focus({ preventScroll: true });
setDragging(true);
fromPointer(event.clientX);
};
const onPointerMove = (event: PointerEvent) => {
if (dragging || (hover && isHoveringPointer(event))) {
fromPointer(event.clientX);
}
};
const endDrag = (event: PointerEvent) => {
releasePointer(event.currentTarget, event.pointerId);
setDragging(false);
};
const onKeyDown = (event: KeyboardEvent) => {
const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP;
let next: number | null = null;
switch (event.key) {
case "ArrowLeft":
case "ArrowDown":
next = percent - step;
break;
case "ArrowRight":
case "ArrowUp":
next = percent + step;
break;
case "PageDown":
next = percent - KEY_STEP_LARGE;
break;
case "PageUp":
next = percent + KEY_STEP_LARGE;
break;
case "Home":
next = 0;
break;
case "End":
next = 100;
break;
default:
return;
}
event.preventDefault();
setValue(next);
};
return (
{showLabels ? (
<>
{beforeLabel}
{afterLabel}
>
) : null}
);
}