# Caliper — Animated UI Component for React

> 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.

- Page: https://crucibleui.com/components/utility/caliper
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/caliper
```

Registry JSON: https://crucibleui.com/r/caliper.json

Import from `@/components/crucible/caliper`.

## Props (`CaliperProps`)

| 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: `Omit<React.ComponentPropsWithoutRef<"div">, "onChange">`.

## Usage example

```tsx
import { Caliper } from "@/components/crucible/caliper";
import { cn } from "@/lib/utils";

/** The same panel rendered in two states — Caliper diffs entire component trees, not just images. */
function Panel({ tone }: { tone: "before" | "after" }) {
  const before = tone === "before";
  const bars = [62, 38, 74, 46, 88];
  return (
    <div
      className={cn(
        "flex h-full w-full flex-col justify-between p-6",
        before
          ? "bg-gradient-to-br from-neutral-800 to-neutral-950"
          : "bg-gradient-to-br from-sky-500/25 via-indigo-500/15 to-neutral-950"
      )}
    >
      <div className="flex items-start justify-between">
        <div>
          <p className="text-[11px] font-medium uppercase tracking-[0.2em] text-neutral-500">
            {before ? "Flat" : "Vivid"}
          </p>
          <h3 className={cn("mt-1 text-lg font-semibold", before ? "text-neutral-300" : "text-white")}>
            Weekly overview
          </h3>
        </div>
        <span
          className={cn(
            "rounded-full px-2.5 py-1 text-[11px] font-semibold tabular-nums",
            before ? "bg-white/5 text-neutral-400" : "bg-sky-400/15 text-sky-300"
          )}
        >
          +18%
        </span>
      </div>

      <div className="flex items-end gap-2.5">
        {bars.map((h, i) => (
          <div key={i} className="flex-1">
            <div
              className={cn("w-full rounded-t-sm", before ? "bg-neutral-700" : "bg-gradient-to-t from-sky-500 to-indigo-400")}
              style={{ height: `${h}px`, boxShadow: before ? undefined : "0 0 14px rgba(56,189,248,0.35)" }}
            />
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        {["Sessions", "Signups", "Revenue"].map((label) => (
          <span
            key={label}
            className={cn(
              "rounded-md border px-2 py-1 text-[11px]",
              before ? "border-white/5 text-neutral-500" : "border-sky-400/20 text-neutral-200"
            )}
          >
            {label}
          </span>
        ))}
      </div>
    </div>
  );
}

export default function CaliperDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full flex-col items-center justify-center gap-6 overflow-hidden rounded-xl bg-neutral-950 p-6">
      <div className="h-64 w-full max-w-md overflow-hidden rounded-xl border border-white/10 shadow-2xl">
        <Caliper
          before={<Panel tone="before" />}
          after={<Panel tone="after" />}
          initial={52}
          beforeLabel="Before"
          afterLabel="After"
        />
      </div>
      <p className="text-sm text-neutral-500">Drag to compare — or focus the grip and use arrow keys.</p>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`caliper.tsx`)

```tsx
"use client";

import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/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);
      // Hover mode has no release — the split settles when the pointer leaves.
      commit();
    }
  };

  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.
          Not aria-hidden as a whole: the grip inside is the focusable slider. */}
      <motion.div
        className={cn("pointer-events-none absolute z-10", isVertical ? "inset-y-0" : "inset-x-0")}
        style={isVertical ? { left: linePos } : { top: linePos }}
      >
        <div aria-hidden 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
          aria-hidden
          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>
  );
}
```
