Slider Vernier

Slider

Numeric input built as a true vernier caliper: a fixed coarse tick row over a sliding fine row at 0.9x pitch, where the accent-lit fine tick physically lines up with a main tick to spell the last digit — drag the body for fast absolute moves, drag the vernier scale itself for one-step-per-tick precision.

Install
npx shadcn add https://design.helpmarq.com/r/slider-vernier.json
Source
registry/core/slider-vernier/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// VernierSlip — a numeric input built as a true vernier caliper: a fixed
// coarse tick row (one tick per 10*step, "UNIT") sits above a sliding fine
// row whose pitch is 0.9x the coarse pitch. Given quantized value v, let
// totalSteps = round((v-min)/step), fineIdx = totalSteps mod 10 (0..9),
// coarseIdx = (totalSteps-fineIdx)/10. Real vernier math: fine tick #fineIdx
// coincides with main tick #(coarseIdx+fineIdx), not #coarseIdx — the
// matching line drifts down the fixed scale as the reading grows, exactly
// as on a physical caliper. That fine tick gets --accent plus a hairline
// back to its coincidence partner. Two drag zones on the same track: upper
// half ("body") maps pointer position to value absolutely (fast, spans the
// whole range); lower half (the vernier row itself) maps pointer DELTA to
// value at one step per fine-tick-width (slow, precise). Both zones commit
// through the same quantize(step) pipeline — the split changes sensitivity,
// not resolution. Release, keyboard nav, and external value changes ease
// the visual (never the already-committed value) toward the settled
// position on an ease-out-expo tween; raw drag tracks the pointer 1:1 so
// the coincidence is something you visibly chase into alignment. Direct-DOM
// rAF for the tween only (sleeps otherwise); tick highlighting is ordinary
// React state, since it only changes at quantized-value granularity. All
// ink is currentColor via token utility classes — no canvas, no derived hex.
// ---------------------------------------------------------------------------

const FINE_RATIO = 0.9; // vernier pitch = 0.9x coarse pitch — the actual geometry
const MIN_PITCH = 18;
const MAX_PITCH = 56;
const SETTLE_MS = 260;
const H = 84; // svg content height
const COARSE_BASELINE = 15;
const COARSE_TIP_MINOR = 24;
const COARSE_TIP_MAJOR = 30;
const COARSE_LABEL_Y = 8;
const FINE_BASELINE = 68;
const FINE_TIP = 54;
const FINE_LABEL_Y = 78;

function easeOutExpo(t: number) {
  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
}

export interface VernierSlipProps {
  /** controlled value; omit for uncontrolled */
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  /** resolution — the fine (vernier) scale reveals exactly this last digit */
  step?: number;
  /** accessible name, also shown as the eyebrow caption */
  label?: string;
  /** unit suffix rendered beside the value, e.g. "mg", "dB" */
  unit?: string;
  /** aria-valuetext override */
  formatValue?: (v: number) => string;
  onValueChange?: (value: number) => void;
  className?: string;
}

export function VernierSlip({
  value,
  defaultValue = 1,
  min = 0,
  max = 10,
  step = 0.1,
  label = "Value",
  unit,
  formatValue,
  onValueChange,
  className = "",
}: VernierSlipProps) {
  const safeStep = step > 0 ? step : 0.1;
  const safeMax = max > min ? max : min + safeStep * 10;
  const decimals = (() => {
    const f = String(safeStep).split(".")[1];
    return f ? f.length : 0;
  })();
  const UNIT = safeStep * 10; // coarse pitch, in value units

  const quantize = (v: number) => {
    const q = min + Math.round((v - min) / safeStep) * safeStep;
    return Number(Math.min(safeMax, Math.max(min, q)).toFixed(6));
  };

  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(() =>
    quantize(defaultValue)
  );
  const current = isControlled ? quantize(value as number) : internal;
  const currentRef = useRef(current);
  currentRef.current = current;

  const fmtValue =
    formatValue ?? ((v: number) => `${v.toFixed(decimals)}${unit ? ` ${unit}` : ""}`);

  // -- index math: which fine tick lights up, and which main tick it meets --
  const totalSteps = Math.round((current - min) / safeStep);
  const fineIdx = ((totalSteps % 10) + 10) % 10;
  const coarseIdx = (totalSteps - fineIdx) / 10;
  const matchIdx = coarseIdx + fineIdx;

  const totalStepsMax = Math.round((safeMax - min) / safeStep);
  const coarseIdxMax = Math.max(1, Math.ceil(totalStepsMax / 10));
  // +9 padding so the coincidence tick (which can drift up to 9 ticks past
  // the "reading" index) always has a rendered main tick to land on
  const numCoarseTicks = coarseIdxMax + 9;

  const [pitch, setPitch] = useState(32);
  const trackRef = useRef<HTMLDivElement>(null);
  const fineGroupRef = useRef<SVGGElement>(null);
  const hairRef = useRef<SVGLineElement>(null);
  const pointerRef = useRef<SVGLineElement>(null);

  const pitchRef = useRef(pitch);
  pitchRef.current = pitch;
  const matchIdxRef = useRef(matchIdx);
  matchIdxRef.current = matchIdx;
  const fineIdxRef = useRef(fineIdx);
  fineIdxRef.current = fineIdx;

  // fit coarse pitch to the container; falls back to horizontal scroll
  // (track has overflow-x-auto) for domains too wide even at MIN_PITCH
  useEffect(() => {
    const track = trackRef.current;
    if (!track) return;
    const fit = (w: number) =>
      Math.min(MAX_PITCH, Math.max(MIN_PITCH, w / Math.max(1, numCoarseTicks)));
    setPitch(fit(track.clientWidth));
    const ro = new ResizeObserver((entries) => {
      const w = entries[0]?.contentRect.width ?? track.clientWidth;
      setPitch(fit(w));
    });
    ro.observe(track);
    return () => ro.disconnect();
  }, [numCoarseTicks]);

  const commitRef = useRef<(v: number) => void>(() => {});
  commitRef.current = (v: number) => {
    if (!isControlled) setInternal(v);
    onValueChange?.(v);
  };

  const engineRef = useRef<{
    dragStart: (clientX: number, clientY: number) => void;
    dragMove: (clientX: number) => void;
    dragEnd: () => void;
    glideTo: (v: number) => void;
  } | null>(null);

  // -- drag / settle engine: direct DOM, refs only, never React state -------
  useEffect(() => {
    const track = trackRef.current;
    const fineGroup = fineGroupRef.current;
    const hair = hairRef.current;
    const pointer = pointerRef.current;
    if (!track || !fineGroup || !hair || !pointer) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    const localQuantize = (v: number) => {
      const q = min + Math.round((v - min) / safeStep) * safeStep;
      return Number(Math.min(safeMax, Math.max(min, q)).toFixed(6));
    };

    let raw = currentRef.current; // continuous, unquantized while dragging
    let visX = 0; // current visual anchor of the fine group, world px
    let targetX = 0;
    let dragging = false;
    let dragMode: "coarse" | "fine" | null = null;
    let dragStartClientX = 0;
    let dragStartValue = 0;
    let raf = 0;
    let tweenStart = 0;
    let tweenFrom = 0;

    const pxPerUnit = () => pitchRef.current / UNIT;
    const xFor = (v: number) => (v - min) * pxPerUnit();

    const render = (x: number) => {
      visX = x;
      fineGroup.style.transform = `translate3d(${x.toFixed(2)}px,0,0)`;
      const finePitch = pitchRef.current * FINE_RATIO;
      const hx2 = x + fineIdxRef.current * finePitch;
      const hx1 = matchIdxRef.current * pitchRef.current;
      hair.setAttribute("x1", hx1.toFixed(2));
      hair.setAttribute("x2", hx2.toFixed(2));
      pointer.setAttribute("x1", x.toFixed(2));
      pointer.setAttribute("x2", x.toFixed(2));
    };

    render(xFor(raw));

    const loop = (now: number) => {
      raf = 0;
      if (dragging) return;
      const t = Math.min(1, (now - tweenStart) / SETTLE_MS);
      render(tweenFrom + (targetX - tweenFrom) * easeOutExpo(t));
      if (t < 1) raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!raf) raf = requestAnimationFrame(loop);
    };

    const settleTo = (v: number) => {
      targetX = xFor(v);
      raw = v;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        render(targetX);
        return;
      }
      tweenFrom = visX;
      tweenStart = performance.now();
      wake();
    };

    const dragMove = (clientX: number) => {
      if (!dragging) return;
      const rect = track.getBoundingClientRect();
      let nextRaw: number;
      if (dragMode === "coarse") {
        nextRaw = min + (clientX - rect.left) / pxPerUnit();
      } else {
        const finePitch = pitchRef.current * FINE_RATIO;
        const deltaSteps = (clientX - dragStartClientX) / finePitch;
        nextRaw = dragStartValue + deltaSteps * safeStep;
      }
      nextRaw = Math.min(safeMax, Math.max(min, nextRaw));
      raw = nextRaw;
      render(xFor(raw));
      const q = localQuantize(raw);
      if (q !== currentRef.current) commitRef.current(q);
    };

    const dragStart = (clientX: number, clientY: number) => {
      const rect = track.getBoundingClientRect();
      dragMode = clientY - rect.top < H / 2 ? "coarse" : "fine";
      dragging = true;
      dragStartClientX = clientX;
      dragStartValue = currentRef.current;
      cancelAnimationFrame(raf);
      raf = 0;
      dragMove(clientX);
    };

    const dragEnd = () => {
      if (!dragging) return;
      dragging = false;
      dragMode = null;
      settleTo(currentRef.current);
    };

    const glideTo = (v: number) => {
      if (dragging) return;
      settleTo(v);
    };

    engineRef.current = { dragStart, dragMove, dragEnd, glideTo };

    return () => {
      cancelAnimationFrame(raf);
      engineRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [min, safeMax, safeStep, UNIT]);

  useEffect(() => {
    engineRef.current?.glideTo(current);
  }, [current]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const v = currentRef.current;
    let next: number | null = null;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = v + safeStep;
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = v - safeStep;
        break;
      case "PageUp":
        next = v + UNIT;
        break;
      case "PageDown":
        next = v - UNIT;
        break;
      case "Home":
        next = min;
        break;
      case "End":
        next = safeMax;
        break;
      default:
        return;
    }
    e.preventDefault();
    next = quantize(next);
    if (next !== v) commitRef.current(next);
  };

  const finePitch = pitch * FINE_RATIO;
  const labelStride = pitch < 26 ? 5 : pitch < 40 ? 2 : 1;

  const coarseTicks = useMemo(() => {
    const arr: { i: number; major: boolean; v: number }[] = [];
    for (let i = 0; i <= numCoarseTicks; i++) {
      arr.push({ i, major: i % labelStride === 0, v: min + i * UNIT });
    }
    return arr;
  }, [numCoarseTicks, labelStride, min, UNIT]);

  const fineTicks = useMemo(() => {
    const arr: { k: number }[] = [];
    for (let k = 0; k <= 10; k++) arr.push({ k });
    return arr;
  }, []);

  const svgWidth = numCoarseTicks * pitch;
  const str = current.toFixed(decimals);
  const restStr = decimals > 0 ? str.slice(0, -1) : str;
  const digitStr = decimals > 0 ? str.slice(-1) : "";

  return (
    <div
      className={`w-full max-w-md rounded-md border border-border bg-surface p-4 font-mono ${className}`}
    >
      <div className="flex items-baseline justify-between gap-3 pb-3">
        <span className="text-[10px] uppercase tracking-[0.2em] text-muted">
          {label}
        </span>
        <div className="flex items-baseline gap-1">
          <span
            aria-hidden
            className="text-2xl font-semibold tabular-nums text-foreground"
          >
            {restStr}
            {digitStr ? (
              <span className="text-accent">{digitStr}</span>
            ) : null}
          </span>
          {unit ? (
            <span className="text-xs text-muted">{unit}</span>
          ) : null}
        </div>
      </div>

      <div
        ref={trackRef}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-orientation="horizontal"
        aria-valuemin={min}
        aria-valuemax={safeMax}
        aria-valuenow={current}
        aria-valuetext={fmtValue(current)}
        data-vernier-track
        onKeyDown={onKeyDown}
        onPointerDown={(e) => {
          e.preventDefault();
          trackRef.current?.setPointerCapture(e.pointerId);
          trackRef.current?.focus({ preventScroll: true });
          engineRef.current?.dragStart(e.clientX, e.clientY);
        }}
        onPointerMove={(e) => engineRef.current?.dragMove(e.clientX)}
        onPointerUp={() => engineRef.current?.dragEnd()}
        onPointerCancel={() => engineRef.current?.dragEnd()}
        className="relative block w-full touch-none select-none overflow-x-auto rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
      >
        <svg
          width={svgWidth}
          height={H}
          className="block cursor-ew-resize"
          aria-hidden
        >
          {/* zone divider — subtle, purely decorative */}
          <line
            x1={0}
            x2={svgWidth}
            y1={H / 2}
            y2={H / 2}
            className="text-border"
            stroke="currentColor"
            strokeWidth={1}
            strokeDasharray="1 4"
          />

          {/* raw drag position — full-height guide, tracks the pointer 1:1 */}
          <line
            ref={pointerRef}
            y1={0}
            y2={H}
            className="text-foreground/15"
            stroke="currentColor"
            strokeWidth={1}
          />

          {/* coarse row — fixed, never translates */}
          <g>
            {coarseTicks.map(({ i, major, v }) => {
              const x = i * pitch;
              const isMatch = i === matchIdx;
              const tip = isMatch
                ? COARSE_TIP_MAJOR + 4
                : major
                  ? COARSE_TIP_MAJOR
                  : COARSE_TIP_MINOR;
              return (
                <g key={i}>
                  <line
                    x1={x}
                    x2={x}
                    y1={COARSE_BASELINE}
                    y2={tip}
                    className={isMatch ? "text-accent" : "text-foreground/70"}
                    stroke="currentColor"
                    strokeWidth={isMatch ? 2 : 1}
                  />
                  {major && pitch >= 22 ? (
                    <text
                      x={x}
                      y={COARSE_LABEL_Y}
                      textAnchor={x < 6 ? "start" : x > svgWidth - 6 ? "end" : "middle"}
                      className={
                        isMatch
                          ? "fill-accent font-medium"
                          : "fill-muted"
                      }
                      style={{ fontSize: 9 }}
                    >
                      {v.toFixed(Math.max(0, decimals - 1))}
                    </text>
                  ) : null}
                </g>
              );
            })}
            <line
              x1={0}
              x2={svgWidth}
              y1={COARSE_BASELINE}
              y2={COARSE_BASELINE}
              className="text-border"
              stroke="currentColor"
              strokeWidth={1}
            />
          </g>

          {/* connecting hairline — the coincidence, drawn live each frame */}
          <line
            ref={hairRef}
            y1={COARSE_TIP_MAJOR}
            y2={FINE_TIP}
            className="text-accent"
            stroke="currentColor"
            strokeWidth={1.25}
            strokeDasharray="2 2"
          />

          {/* fine (vernier) row — a rigid group that translates as one body */}
          <g ref={fineGroupRef}>
            <line
              x1={0}
              x2={10 * finePitch}
              y1={FINE_BASELINE}
              y2={FINE_BASELINE}
              className="text-border"
              stroke="currentColor"
              strokeWidth={1}
            />
            {fineTicks.map(({ k }) => {
              const x = k * finePitch;
              const isMatch = k === fineIdx;
              return (
                <g key={k}>
                  <line
                    x1={x}
                    x2={x}
                    y1={FINE_BASELINE}
                    y2={isMatch ? FINE_TIP - 4 : FINE_TIP}
                    className={
                      isMatch
                        ? "text-accent"
                        : k <= 9
                          ? "text-foreground/70"
                          : "text-foreground/30"
                    }
                    stroke="currentColor"
                    strokeWidth={isMatch ? 2 : 1}
                  />
                  {k <= 9 ? (
                    <text
                      x={x}
                      y={FINE_LABEL_Y}
                      textAnchor="middle"
                      className={
                        isMatch ? "fill-accent font-semibold" : "fill-muted"
                      }
                      style={{ fontSize: 9 }}
                    >
                      {k}
                    </text>
                  ) : null}
                </g>
              );
            })}
          </g>
        </svg>
      </div>

      <div className="flex items-center justify-between pt-2 font-mono text-[10px] text-muted">
        <span>drag body to set · drag scale below to fine-tune</span>
        <span aria-hidden className="tabular-nums">
          {min.toFixed(Math.max(0, decimals - 1))}–{safeMax.toFixed(Math.max(0, decimals - 1))}
        </span>
      </div>
    </div>
  );
}
Use when

a numeric field whose last significant digit deserves ceremony — dosages, gain trims, calibration offsets — where the value is set by dragging against a real vernier scale (coarse row for whole units, sliding fine row for the exact final digit) and precision is the point; pick slider-loupe instead when the value is approximate and the magnified-tick reading is just flavor, or stepper-needle when discrete +/- stepping with an adjustment history matters more than drag precision.

Build spec

Build a numeric input as a working vernier caliper, not a decorated slider. GEOMETRY: a fixed coarse tick row (one tick per 10*step) sits above a sliding fine row of 11 ticks whose pitch is exactly 0.9x the coarse pitch. For quantized value v, totalSteps = round((v-min)/step), fineIdx = totalSteps mod 10, coarseIdx = (totalSteps-fineIdx)/10; the real caliper identity is that fine tick #fineIdx coincides with MAIN tick #(coarseIdx+fineIdx) — not #coarseIdx — so the coincidence point drifts down the fixed scale as the reading grows, exactly like the physical instrument. The matching fine tick and its coincidence partner both light --accent, joined by a dashed accent hairline redrawn every frame; render 9 extra padding ticks past the domain max so the coincidence always has a partner to land on. INTERACTION: one track, two drag zones split at half height — the upper half maps pointer position to value ABSOLUTELY (fast, spans the whole range), the lower half (the vernier row itself) maps pointer DELTA at one step per fine-tick-width (slow, precise); both commit through the same quantize(step) pipeline so the split changes sensitivity, never resolution. While dragging, the fine group tracks the pointer 1:1 (raw, unquantized) so the user visibly chases the coincidence into alignment; on release, keyboard nav, or external value change, the visual eases to the settled quantized position on a 260ms ease-out-expo tween. The tween is direct-DOM rAF writing transform/x attributes to refs (sleeps when settled, cancelled on unmount); tick highlighting is ordinary React state since it only changes at quantized-value granularity. prefers-reduced-motion snaps to the settled position with no tween. Pointer capture on the track; a faint full-height guide line tracks the raw pointer. ACCESSIBILITY: the track is role=slider tabIndex=0 with aria-valuemin/max/now and aria-valuetext (formatValue override supported), ArrowLeft/Right/Up/Down step by step, PageUp/Down by 10*step, Home/End to the rails; the SVG is aria-hidden and the big mono readout (last digit tinted --accent to mirror the vernier) is aria-hidden too, since the slider itself carries the value. LAYOUT: coarse pitch auto-fits the container via ResizeObserver (clamped 18-56px, labels drop below 22px, label stride widens as pitch shrinks), overflowing domains fall back to horizontal scroll. INK: everything is currentColor via token utility classes (text-foreground/70, text-border, text-accent, fill-muted) — no canvas, no hex.

Tags
sliderinputnumericprecisionsvgdragformmicro-interaction