"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. {alt ); } 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}
); }