Caliper
Before/after comparison slider that clips a leading layer with a draggable divider. Machinist's-caliper hairline with tick marks, a knurled grip, a live vernier readout, and a 50% detent. Accepts arbitrary children, keyboard-accessible, hover/drag/autoplay modes.
motionfree
"use client";
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { useVisibilityPause } from "@/registry/default/hooks/use-visibility-pause/use-visibility-pause";
import { cn } from "@/lib/utils";
export interface CaliperProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> {
/** Leading content — revealed from the start edge up to the divider (the "before"). */
before: React.ReactNode;
/** Trailing content — fills the frame beneath the leading layer (the "after"). */
after: React.ReactNode;
/** Starting split as a percentage, 0–100. @default 50 */
initial?: number;
/** `drag` requires a press to move the divider; `hover` follows the pointer on move. @default "drag" */
mode?: "drag" | "hover";
/** Divider axis. `vertical` splits left/right; `horizontal` splits top/bottom. @default "vertical" */
orientation?: "vertical" | "horizontal";
/** Render the knurled grip on the divider. @default true */
handle?: boolean;
/** Sweep the divider back and forth on a timer while idle (ignored under reduced motion). @default false */
autoplay?: boolean;
/** Seconds for one full autoplay sweep, there and back. @default 6 */
autoplayDuration?: number;
/** Beam / tick / grip color — the bright neutral hairline. @default "#f5f5f5" */
color?: string;
/** Cool accent for the live readout and detent pulse. @default "#7dd3fc" */
accent?: string;
/** Optional short chip pinned to the leading side. */
beforeLabel?: React.ReactNode;
/** Optional short chip pinned to the trailing side. */
afterLabel?: React.ReactNode;
/** Fires with the rounded split percentage whenever the divider settles. */
onSplitChange?: (percent: number) => void;
/** Extra classes for the leading (before) layer. */
beforeClassName?: string;
/** Extra classes for the trailing (after) layer. */
afterClassName?: string;
}
const clamp = (n: number) => Math.min(100, Math.max(0, n));
// Drag inertia — the beam has a whisper of mass, then settles crisply.
const SPRING = { stiffness: 320, damping: 30, mass: 0.7 } as const;
/**
* Caliper — a before/after comparison slider. Two layers occupy one frame; a
* vertical (or horizontal) divider clips the leading layer so dragging — or
* hovering across, in hover mode — sweeps the reveal boundary and lets the eye
* compare two states. Accepts arbitrary children, not just images: diff two
* entire component trees.
*
* Crucible signature: a machinist's caliper — neutral beam with one cool accent.
* The divider is a thin bright hairline capped with fine tick marks and a
* knurled grip; a tiny live percentage readout fades in while you drag and
* evaporates on release, like reading a vernier scale. The beam moves with a
* whisper of spring inertia, a 1px detent tick brightens as it crosses 50%, and
* the grip is a focusable slider — arrows nudge 1%, Home/End snap to either
* side, value announced. Under reduced motion the spring and autoplay are
* dropped for direct 1:1 tracking. Clip-path compositing only; no per-frame
* layout.
*/
export function Caliper({
before,
after,
initial = 50,
mode = "drag",
orientation = "vertical",
handle = true,
autoplay = false,
autoplayDuration = 6,
color = "#f5f5f5",
accent = "#7dd3fc",
beforeLabel,
afterLabel,
onSplitChange,
beforeClassName,
afterClassName,
className,
...props
}: CaliperProps) {
const reducedMotion = useReducedMotion();
const isVertical = orientation === "vertical";
const raw = useMotionValue(clamp(initial));
const springed = useSpring(raw, SPRING);
const tracked = reducedMotion ? raw : springed;
const activeRef = React.useRef(false);
const [interacting, setInteracting] = React.useState(false);
// Once the user grabs the divider, autoplay yields for good (no jarring resume).
const [engaged, setEngaged] = React.useState(false);
const [display, setDisplay] = React.useState(() => Math.round(clamp(initial)));
const displayRef = React.useRef(display);
const hideTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const onChangeRef = React.useRef(onSplitChange);
onChangeRef.current = onSplitChange;
// Keep the announced value + readout in sync with the tracked value, but only
// re-render on whole-percent changes so a full sweep costs ~100 updates, not
// one per frame. The clip itself is a pure MotionValue transform (no React).
useMotionValueEvent(tracked, "change", (v) => {
const r = Math.round(v);
if (r !== displayRef.current) {
displayRef.current = r;
setDisplay(r);
}
});
const clipPath = useTransform(tracked, (p) =>
isVertical ? `inset(0 ${100 - p}% 0 0)` : `inset(0 0 ${100 - p}% 0)`
);
const linePos = useTransform(tracked, (p) => `${p}%`);
const commit = React.useCallback(() => {
onChangeRef.current?.(Math.round(raw.get()));
}, [raw]);
const set = React.useCallback(
(next: number) => {
raw.set(clamp(next));
},
[raw]
);
// Autoplay: a cosine sweep between two insets, paused while the user is
// interacting, offscreen, or on a hidden tab. Reduced motion opts out entirely.
const autoplayPaused = !autoplay || reducedMotion || interacting || engaged;
const rootRef = useVisibilityPause<HTMLDivElement>(
(elapsed) => {
const period = Math.max(0.5, autoplayDuration);
const phase = (elapsed % period) / period;
const eased = 0.5 - 0.5 * Math.cos(phase * Math.PI * 2);
raw.set(8 + eased * 84);
},
{ paused: autoplayPaused }
);
const updateFromPointer = React.useCallback(
(clientX: number, clientY: number) => {
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const p = isVertical
? ((clientX - rect.left) / rect.width) * 100
: ((clientY - rect.top) / rect.height) * 100;
set(p);
},
[isVertical, rootRef, set]
);
const showReadout = React.useCallback(() => {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
setInteracting(true);
}, []);
const scheduleHide = React.useCallback((ms: number) => {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setInteracting(false), ms);
}, []);
React.useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[]
);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0 && e.pointerType === "mouse") return;
e.currentTarget.setPointerCapture(e.pointerId);
activeRef.current = true;
setEngaged(true);
showReadout();
updateFromPointer(e.clientX, e.clientY);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (activeRef.current) {
updateFromPointer(e.clientX, e.clientY);
return;
}
if (mode === "hover" && e.pointerType === "mouse") {
showReadout();
updateFromPointer(e.clientX, e.clientY);
}
};
const endPress = (e: React.PointerEvent<HTMLDivElement>) => {
if (!activeRef.current) return;
activeRef.current = false;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* pointer already released */
}
commit();
if (mode === "drag" || e.pointerType !== "mouse") setInteracting(false);
};
const onPointerLeave = () => {
if (mode === "hover" && !activeRef.current) setInteracting(false);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const current = Math.round(raw.get());
let next: number | null = null;
switch (e.key) {
case "ArrowLeft":
case "ArrowDown":
next = current - 1;
break;
case "ArrowRight":
case "ArrowUp":
next = current + 1;
break;
case "PageDown":
next = current - 10;
break;
case "PageUp":
next = current + 10;
break;
case "Home":
next = 0;
break;
case "End":
next = 100;
break;
default:
return;
}
e.preventDefault();
setEngaged(true);
set(next);
commit();
showReadout();
scheduleHide(1000);
};
const nearDetent = Math.abs(display - 50) <= 1;
// Grip orientation: a pill along the divider with knurl lines and drag chevrons.
const grip = handle ? (
<div
role="slider"
tabIndex={0}
aria-label="Comparison split"
aria-orientation={orientation}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={display}
aria-valuetext={`${display}% revealed`}
onKeyDown={onKeyDown}
className={cn(
"pointer-events-auto absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 touch-none select-none items-center justify-center rounded-full border outline-none",
"border-white/20 bg-neutral-950/70 shadow-[0_2px_12px_rgba(0,0,0,0.55)] backdrop-blur-sm",
"transition-[transform,box-shadow,border-color] duration-200 hover:scale-105",
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
isVertical ? "h-11 w-7 cursor-ew-resize flex-col gap-[3px]" : "h-7 w-11 cursor-ns-resize flex-row gap-[3px]"
)}
style={
{
"--tw-ring-color": accent,
boxShadow: interacting ? `0 0 0 1px ${color}55, 0 2px 16px rgba(0,0,0,0.6)` : undefined,
} as React.CSSProperties
}
>
{/* knurl lines — fine ridges across the grip face */}
<span aria-hidden className={cn("flex", isVertical ? "flex-col gap-[3px]" : "flex-row gap-[3px]")}>
<i className={cn("block rounded-full opacity-70", isVertical ? "h-px w-3" : "h-3 w-px")} style={{ background: color }} />
<i className={cn("block rounded-full opacity-70", isVertical ? "h-px w-3" : "h-3 w-px")} style={{ background: color }} />
<i className={cn("block rounded-full opacity-70", isVertical ? "h-px w-3" : "h-3 w-px")} style={{ background: color }} />
</span>
</div>
) : null;
return (
<div
ref={rootRef}
data-crucible="caliper"
className={cn(
"relative isolate h-full w-full touch-none select-none overflow-hidden rounded-xl",
mode === "hover" ? "cursor-crosshair" : isVertical ? "cursor-ew-resize" : "cursor-ns-resize",
className
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPress}
onPointerCancel={endPress}
onPointerLeave={onPointerLeave}
{...props}
>
{/* Trailing layer (after) — fills the frame */}
<div className={cn("absolute inset-0", afterClassName)}>{after}</div>
{/* Leading layer (before) — clipped to the split via clip-path only */}
<motion.div className={cn("absolute inset-0", beforeClassName)} style={{ clipPath, willChange: "clip-path" }}>
{before}
</motion.div>
{/* Optional side labels */}
{(beforeLabel || afterLabel) && (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3">
<span className="rounded-full border border-white/10 bg-neutral-950/60 px-2.5 py-1 text-[11px] font-medium tracking-wide text-neutral-300 backdrop-blur-sm">
{beforeLabel}
</span>
<span className="rounded-full border border-white/10 bg-neutral-950/60 px-2.5 py-1 text-[11px] font-medium tracking-wide text-neutral-300 backdrop-blur-sm">
{afterLabel}
</span>
</div>
)}
{/* Static 50% detent reference — a 1px tick that brightens as the beam nears it */}
<div
aria-hidden
className={cn(
"pointer-events-none absolute z-10 transition-opacity duration-200",
isVertical ? "top-0 left-1/2 h-2.5 w-px -translate-x-1/2" : "top-1/2 left-0 h-px w-2.5 -translate-y-1/2"
)}
style={{ background: nearDetent ? accent : color, opacity: nearDetent ? 0.95 : 0.28 }}
/>
{/* Divider — the bright beam, capped with tick marks, carrying the grip */}
<motion.div
aria-hidden
className={cn("pointer-events-none absolute z-10", isVertical ? "inset-y-0" : "inset-x-0")}
style={isVertical ? { left: linePos } : { top: linePos }}
>
<div className={cn("absolute", isVertical ? "inset-y-0 left-1/2 -translate-x-1/2" : "inset-x-0 top-1/2 -translate-y-1/2")}>
{/* the hairline beam + soft glow */}
<div
className={cn("absolute", isVertical ? "inset-y-0 left-1/2 w-px -translate-x-1/2" : "inset-x-0 top-1/2 h-px -translate-y-1/2")}
style={{ background: color, boxShadow: `0 0 10px ${color}66` }}
/>
{/* caliper jaw ticks at both ends */}
<span
className={cn("absolute", isVertical ? "top-0 left-1/2 h-px w-2.5 -translate-x-1/2" : "left-0 top-1/2 h-2.5 w-px -translate-y-1/2")}
style={{ background: color, opacity: 0.85 }}
/>
<span
className={cn("absolute", isVertical ? "bottom-0 left-1/2 h-px w-2.5 -translate-x-1/2" : "right-0 top-1/2 h-2.5 w-px -translate-y-1/2")}
style={{ background: color, opacity: 0.85 }}
/>
</div>
{/* live readout — fades in while interacting, evaporates on release */}
<div
className={cn(
"absolute left-1/2 flex -translate-x-1/2 items-center justify-center transition-all duration-200",
isVertical ? "top-2" : "top-1/2 -translate-y-1/2 ml-4",
interacting ? "opacity-100 blur-0" : "translate-y-0 opacity-0 blur-[2px]"
)}
>
<span
className="rounded-md border border-white/15 bg-neutral-950/80 px-2 py-0.5 text-[11px] font-semibold tabular-nums backdrop-blur-sm"
style={{ color: accent, textShadow: `0 0 8px ${accent}55` }}
>
{display}%
</span>
</div>
{grip}
</motion.div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/caliperManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| before | React.ReactNode | Leading content — revealed from the start edge up to the divider (the "before"). | |
| after | React.ReactNode | Trailing content — fills the frame beneath the leading layer (the "after"). | |
| initial | number | 50 | Starting split as a percentage, 0–100. |
| mode | "drag" | "hover" | "drag" | drag requires a press to move the divider; hover follows the pointer on move. |
| orientation | "vertical" | "horizontal" | "vertical" | Divider axis. vertical splits left/right; horizontal splits top/bottom. |
| handle | boolean | true | Render the knurled grip on the divider. |
| autoplay | boolean | false | Sweep the divider back and forth on a timer while idle (ignored under reduced motion). |
| autoplayDuration | number | 6 | Seconds for one full autoplay sweep, there and back. |
| color | string | "#f5f5f5" | Beam / tick / grip color — the bright neutral hairline. |
| accent | string | "#7dd3fc" | Cool accent for the live readout and detent pulse. |
| beforeLabel | React.ReactNode | Optional short chip pinned to the leading side. | |
| afterLabel | React.ReactNode | Optional short chip pinned to the trailing side. | |
| onSplitChange | (percent: number) => void | Fires with the rounded split percentage whenever the divider settles. | |
| beforeClassName | string | Extra classes for the leading (before) layer. | |
| afterClassName | string | Extra classes for the trailing (after) layer. | |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "onChange"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.