"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform } from "motion/react";
export interface CompareSliderProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "defaultValue" | "onChange"> {
/** Shown on the start side (left / top). An image URL or any element. */
before: React.ReactNode;
/** Shown on the end side (right / bottom). An image URL or any element. */
after: React.ReactNode;
/** Label for the before side (also the image alt text when `before` is a URL). */
beforeLabel?: string;
/** Label for the after side (also the image alt text when `after` is a URL). */
afterLabel?: string;
/** Show the before / after chips. */
showLabels?: boolean;
/** Split side by side (horizontal) or top / bottom (vertical). */
orientation?: "horizontal" | "vertical";
/** Divider position in % (controlled). */
position?: number;
/** Initial divider position in % (uncontrolled). */
defaultPosition?: number;
/** Called with the new position in % whenever it changes. */
onPositionChange?: (position: number) => void;
/** Move the divider by hovering with a mouse, without pressing. */
followPointer?: boolean;
/** Keyboard step in %. */
step?: number;
/** Handle and focus ring colour. */
accent?: string;
/** Corner radius in px. */
rounded?: number;
/** Accessible name of the divider handle. */
handleLabel?: string;
}
const clamp = (n: number) => Math.min(100, Math.max(0, n));
function Layer({ content, alt }: { content: React.ReactNode; alt: string }) {
if (typeof content === "string") {
// eslint-disable-next-line @next/next/no-img-element -- the shipped component must not depend on next/image
return <img src={content} alt={alt} draggable={false} className="block size-full object-cover" />;
}
return <div className="size-full">{content}</div>;
}
/**
* A before / after comparison with a draggable divider. The before layer is revealed with a
* clip-path driven by a motion value, so dragging never triggers layout or React renders.
* The handle is a keyboard-operable role="slider".
*/
export function CompareSlider({
before,
after,
beforeLabel = "Before",
afterLabel = "After",
showLabels = true,
orientation = "horizontal",
position,
defaultPosition = 50,
onPositionChange,
followPointer = false,
step = 1,
accent = "#FF7448",
rounded = 16,
handleLabel = "Comparison divider",
className = "",
style,
...props
}: CompareSliderProps) {
const horizontal = orientation === "horizontal";
const reduceMotion = useReducedMotion();
const [initial] = React.useState(() => clamp(position ?? defaultPosition));
const pos = useMotionValue(initial);
const rootRef = React.useRef<HTMLDivElement>(null);
const handleRef = React.useRef<HTMLDivElement>(null);
const dragging = React.useRef(false);
const clipPath = useTransform(pos, (v) => (horizontal ? `inset(0 ${100 - v}% 0 0)` : `inset(0 0 ${100 - v}% 0)`));
const offset = useTransform(pos, (v) => `${v}%`);
const beforeOpacity = useTransform(pos, [10, 22], [0, 1]);
const afterOpacity = useTransform(pos, [78, 90], [1, 0]);
const valueText = (v: number) => `${beforeLabel} ${Math.round(v)}%, ${afterLabel} ${100 - Math.round(v)}%`;
// Keep the ARIA value in sync without re-rendering on every pointer move.
useMotionValueEvent(pos, "change", (v) => {
const handle = handleRef.current;
if (!handle) return;
handle.setAttribute("aria-valuenow", String(Math.round(v)));
handle.setAttribute("aria-valuetext", valueText(v));
});
// Controlled mode: follow the prop.
React.useEffect(() => {
if (position === undefined || dragging.current) return;
const target = clamp(position);
if (Math.abs(pos.get() - target) < 0.01) return;
const controls = animate(pos, target, reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 40 });
return () => controls.stop();
}, [position, pos, reduceMotion]);
const setPosition = (next: number, animated = false) => {
const v = clamp(next);
if (animated && !reduceMotion) animate(pos, v, { type: "spring", stiffness: 600, damping: 42 });
else pos.set(v);
onPositionChange?.(v);
};
const fromPointer = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = rootRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0 || rect.height === 0) return;
setPosition(
horizontal ? ((event.clientX - rect.left) / rect.width) * 100 : ((event.clientY - rect.top) / rect.height) * 100,
);
};
const endDrag = () => {
dragging.current = false;
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const current = pos.get();
const big = Math.max(step, 10);
// Right / down move the divider toward the end side; left / up toward the start.
const map: Record<string, number> = {
ArrowRight: current + step,
ArrowDown: current + step,
ArrowLeft: current - step,
ArrowUp: current - step,
PageDown: current + big,
PageUp: current - big,
Home: 0,
End: 100,
};
if (!(event.key in map)) return;
event.preventDefault();
setPosition(map[event.key], true);
};
const chip =
"pointer-events-none absolute rounded-full bg-zinc-950/55 px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white backdrop-blur-sm";
return (
<div
ref={rootRef}
className={`relative isolate select-none overflow-hidden bg-zinc-200 dark:bg-zinc-800 ${horizontal ? "touch-pan-y cursor-ew-resize" : "touch-pan-x cursor-ns-resize"} ${className}`}
style={{ borderRadius: rounded, ["--cs-accent" as string]: accent, ...style }}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
handleRef.current?.focus({ preventScroll: true });
dragging.current = true;
fromPointer(event);
}}
onPointerMove={(event) => {
if (dragging.current || (followPointer && event.pointerType === "mouse")) fromPointer(event);
}}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
{...props}
>
{/* After: in flow, so an image can size the component. */}
<div className="relative size-full">
<Layer content={after} alt={afterLabel} />
</div>
{/* Before: stacked on top and clipped to the divider. */}
<motion.div className="absolute inset-0" style={{ clipPath }}>
<Layer content={before} alt={beforeLabel} />
</motion.div>
{showLabels && (
<>
<motion.span aria-hidden="true" className={`${chip} left-3 top-3`} style={{ opacity: beforeOpacity }}>
{beforeLabel}
</motion.span>
<motion.span
aria-hidden="true"
className={`${chip} ${horizontal ? "right-3 top-3" : "bottom-3 left-3"}`}
style={{ opacity: afterOpacity }}
>
{afterLabel}
</motion.span>
</>
)}
{/* Divider: a full-size layer translated by the position (transform only, no layout). */}
<motion.div className="pointer-events-none absolute inset-0" style={horizontal ? { x: offset } : { y: offset }}>
<span
aria-hidden="true"
className={`absolute bg-white shadow-[0_0_0_1px_rgb(0_0_0/0.12),0_0_12px_rgb(0_0_0/0.25)] ${
horizontal ? "inset-y-0 left-0 w-0.5 -translate-x-1/2" : "inset-x-0 top-0 h-0.5 -translate-y-1/2"
}`}
/>
<div
ref={handleRef}
role="slider"
tabIndex={0}
aria-label={handleLabel}
aria-orientation={orientation}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(position ?? initial)}
aria-valuetext={valueText(position ?? initial)}
onKeyDown={handleKeyDown}
className={`pointer-events-auto absolute grid size-10 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-900 shadow-[0_6px_18px_-6px_rgb(0_0_0/0.45)] outline-none transition-[box-shadow] focus-visible:ring-2 focus-visible:ring-[var(--cs-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white ${
horizontal ? "left-0 top-1/2" : "left-1/2 top-0 rotate-90"
}`}
>
<svg viewBox="0 0 20 20" fill="none" className="size-4" aria-hidden="true">
<path d="M7.5 6L3.5 10l4 4M12.5 6l4 4-4 4" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span aria-hidden="true" className="absolute inset-[3px] rounded-full border border-[var(--cs-accent)] opacity-60" />
</div>
</motion.div>
</div>
);
}