Scroll Caliper

Instrument

A vernier caliper pinned to a scroll container's edge whose spring-damped jaws close over the active section's extent while a mono readout ticks px and percent, tick marks streaking with velocity-scaled motion blur.

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

import { useEffect, useRef, type ReactNode } from "react";

const W = 48; // instrument width in px

// ---------------------------------------------------------------------------
// damped spring integrator — k in s^-2, zeta dimensionless (1 = critical)
// ---------------------------------------------------------------------------
type Spring = { x: number; v: number; t: number };
function stepSpring(s: Spring, k: number, zeta: number, dt: number) {
  const c = 2 * zeta * Math.sqrt(k);
  s.v += (k * (s.t - s.x) - c * s.v) * dt;
  s.x += s.v * dt;
}
function isSettled(s: Spring) {
  return Math.abs(s.x - s.t) < 0.05 && Math.abs(s.v) < 0.05;
}

// bare-usage fallback content: three short measured sections
function FallbackSections() {
  const blocks: [string, string][] = [
    [
      "Datum",
      "Every measurement starts from a reference surface. The caliper treats the top of each section as its datum and closes the opposing jaw on the far edge.",
    ],
    [
      "Extent",
      "The jaw opening reads the section's visible span in pixels. Scroll and the blades chase the new edges with near-critical spring damping, a hair of lag by design.",
    ],
    [
      "Runout",
      "Stop scrolling and the needle over-settles with a visible wobble before locking, the way a real dial indicator bleeds off its last few microns of momentum.",
    ],
  ];
  return (
    <div className="space-y-12 p-8">
      {blocks.map(([title, copy]) => (
        <section key={title} data-section className="space-y-3">
          <h3 className="text-sm font-semibold text-foreground">{title}</h3>
          <p className="text-sm leading-relaxed text-muted">{copy}</p>
          <p className="text-sm leading-relaxed text-muted">{copy}</p>
        </section>
      ))}
    </div>
  );
}

// ---------------------------------------------------------------------------
// ScrollCaliper — a vernier caliper pinned to the right edge of a bounded
// scroll container. Its jaws close over the active [data-section]'s vertical
// extent (spring k=120 s^-2, zeta=0.9), a font-mono readout ticks px/percent
// on a softer spring (k=90, zeta=0.55) so it wobbles when scrolling stops,
// and the tick scale streaks with velocity-scaled motion blur. SVG + direct-
// DOM rAF, refs only; the loop sleeps 150ms after the last scroll once every
// spring settles. All ink is currentColor via token classes (text-border /
// text-foreground / text-accent), so theme flips restyle the SVG live with
// no numeric getComputedStyle reads to re-derive.
// ---------------------------------------------------------------------------
export function ScrollCaliper({
  children,
  sectionSelector = "[data-section]",
  jawStiffness = 120,
  jawDamping = 0.9,
  needleStiffness = 90,
  needleDamping = 0.55,
  className = "h-[480px]",
}: {
  /** scrollable content; sections are matched via sectionSelector */
  children?: ReactNode;
  /** selector for measurable sections inside the scroller */
  sectionSelector?: string;
  /** jaw spring stiffness in s^-2 (near-critical chase) */
  jawStiffness?: number;
  /** jaw damping ratio (1 = critical) */
  jawDamping?: number;
  /** readout needle stiffness in s^-2 (softer, visible over-settle) */
  needleStiffness?: number;
  /** readout needle damping ratio */
  needleDamping?: number;
  className?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const scrollerRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const tickRef = useRef<SVGGElement>(null);
  const minorRef = useRef<SVGPathElement>(null);
  const majorRef = useRef<SVGPathElement>(null);
  const echoRef = useRef<SVGGElement>(null);
  const echoMinorRef = useRef<SVGPathElement>(null);
  const echoMajorRef = useRef<SVGPathElement>(null);
  const spineRef = useRef<SVGPathElement>(null);
  const regionRef = useRef<SVGRectElement>(null);
  const topJawRef = useRef<SVGGElement>(null);
  const botJawRef = useRef<SVGGElement>(null);
  const markerRef = useRef<SVGGElement>(null);
  const chipLabelRef = useRef<HTMLDivElement>(null);
  const chipPxRef = useRef<HTMLSpanElement>(null);
  const chipPctRef = useRef<HTMLSpanElement>(null);
  const liveRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const scroller = scrollerRef.current;
    const svg = svgRef.current;
    const tickG = tickRef.current;
    const minorPath = minorRef.current;
    const majorPath = majorRef.current;
    const echoG = echoRef.current;
    const echoMinor = echoMinorRef.current;
    const echoMajor = echoMajorRef.current;
    const spine = spineRef.current;
    const region = regionRef.current;
    const topJaw = topJawRef.current;
    const botJaw = botJawRef.current;
    const marker = markerRef.current;
    const chipLabel = chipLabelRef.current;
    const chipPx = chipPxRef.current;
    const chipPct = chipPctRef.current;
    const live = liveRef.current;
    if (
      !scroller || !svg || !tickG || !minorPath || !majorPath || !echoG ||
      !echoMinor || !echoMajor || !spine || !region || !topJaw || !botJaw ||
      !marker || !chipLabel || !chipPx || !chipPct || !live
    )
      return;

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

    const sections = Array.from(
      scroller.querySelectorAll<HTMLElement>(sectionSelector)
    );
    const total = sections.length;

    // hot-path state — closure locals only, never React state
    const jawTop: Spring = { x: 0, v: 0, t: 0 };
    const jawBot: Spring = { x: 0, v: 0, t: 0 };
    const needle: Spring = { x: 0, v: 0, t: 0 }; // percent through section
    let H = 0;
    let raf = 0;
    let running = false;
    let last = 0;
    let primed = false;
    let lastScrollT = 0; // performance.now() of last scroll event
    let prevScroll = scroller.scrollTop;
    let vSmooth = 0; // px/s, exponentially smoothed (alpha 0.2)
    let activeIdx = -1;
    let prevPxText = "";
    let prevPctText = "";
    let lastAnnounce = 0;
    let flashTimer: ReturnType<typeof setTimeout> | undefined;
    let announceTimer: ReturnType<typeof setTimeout> | undefined;
    const visiblePx = new Map<Element, number>();

    // tick scale: minor every 2px, major every 8px, long major every 40px —
    // rebuilt on resize only; per-frame motion is a translate of the group
    const rebuild = () => {
      H = scroller.clientHeight;
      if (H <= 0) return; // zero-size container guard
      svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
      let minor = "";
      let major = "";
      for (let y = -8; y <= H + 8; y += 2) {
        if (y % 40 === 0) major += `M${W - 16} ${y}h16`;
        else if (y % 8 === 0) major += `M${W - 10} ${y}h10`;
        else minor += `M${W - 5} ${y}h5`;
      }
      minorPath.setAttribute("d", minor);
      majorPath.setAttribute("d", major);
      echoMinor.setAttribute("d", minor);
      echoMajor.setAttribute("d", major);
      spine.setAttribute("d", `M${W - 0.75} 0V${H}`);
    };
    rebuild();

    // map the active section's extent to container-space jaw targets;
    // needle target = percent of the section swept past the container center
    const measure = () => {
      if (H <= 0) return;
      const cRect = scroller.getBoundingClientRect();
      const el = activeIdx >= 0 ? sections[activeIdx] : undefined;
      if (el) {
        const r = el.getBoundingClientRect();
        jawTop.t = Math.min(H, Math.max(0, r.top - cRect.top));
        jawBot.t = Math.min(H, Math.max(0, r.bottom - cRect.top));
        // zero-height section guard: never divide by a degenerate extent
        needle.t =
          r.height > 0.5
            ? Math.min(1, Math.max(0, (cRect.top + H / 2 - r.top) / r.height)) *
              100
            : 0;
      } else {
        jawTop.t = 0;
        jawBot.t = H;
        const range = scroller.scrollHeight - H;
        needle.t = range > 0 ? (scroller.scrollTop / range) * 100 : 0;
      }
    };

    const loop = (now: number) => {
      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
      last = now;
      const sc = scroller.scrollTop;
      vSmooth += 0.2 * ((sc - prevScroll) / dt - vSmooth);
      prevScroll = sc;

      measure();
      if (reduced || !primed) {
        // reduced motion snaps every frame; first frame snaps to avoid a
        // mount sweep from zero
        jawTop.x = jawTop.t;
        jawBot.x = jawBot.t;
        needle.x = needle.t;
        jawTop.v = jawBot.v = needle.v = 0;
        primed = true;
      } else {
        stepSpring(jawTop, jawStiffness, jawDamping, dt);
        stepSpring(jawBot, jawStiffness, jawDamping, dt);
        stepSpring(needle, needleStiffness, needleDamping, dt);
      }

      // writes — direct DOM, no React
      const phase = -(sc % 8);
      tickG.style.transform = `translateY(${phase.toFixed(2)}px)`;
      topJaw.style.transform = `translateY(${jawTop.x.toFixed(2)}px)`;
      botJaw.style.transform = `translateY(${jawBot.x.toFixed(2)}px)`;
      const gap = Math.max(0, jawBot.x - jawTop.x);
      region.setAttribute("y", jawTop.x.toFixed(2));
      region.setAttribute("height", gap.toFixed(2));
      const pct = Math.min(100, Math.max(0, needle.x));
      marker.style.transform = `translateY(${(jawTop.x + (pct / 100) * gap).toFixed(2)}px)`;

      // motion-blur echo: duplicated tick layer trailing the scroll direction
      if (!reduced) {
        const speed = Math.abs(vSmooth);
        const o = Math.min(0.6, speed / 3000);
        const len = Math.min(10, speed / 200);
        if (o > 0.02) {
          echoG.style.opacity = o.toFixed(3);
          echoG.style.transform = `translateY(${(phase + Math.sign(vSmooth) * len).toFixed(2)}px)`;
          echoG.style.filter = `blur(${(len / 2).toFixed(2)}px)`;
        } else {
          echoG.style.opacity = "0";
          echoG.style.filter = "none";
        }
      }

      const pxText = gap.toFixed(1);
      if (pxText !== prevPxText) {
        chipPx.textContent = pxText;
        prevPxText = pxText;
      }
      const pctText = String(Math.round(pct));
      if (pctText !== prevPctText) {
        chipPct.textContent = pctText;
        prevPctText = pctText;
      }

      // sleep: 150ms since last scroll AND every spring inside the epsilon
      if (
        now - lastScrollT > 150 &&
        isSettled(jawTop) &&
        isSettled(jawBot) &&
        isSettled(needle)
      ) {
        echoG.style.opacity = "0"; // blur layer removed at rest for crispness
        echoG.style.filter = "none";
        vSmooth = 0;
        running = false;
        return;
      }
      raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (!running) {
        running = true;
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const onScroll = () => {
      lastScrollT = performance.now();
      wake();
    };
    scroller.addEventListener("scroll", onScroll, { passive: true });

    // 200ms accent flash of the readout label on section change
    const flash = () => {
      chipLabel.textContent = `SEC ${activeIdx + 1}/${total}`;
      chipLabel.classList.remove("text-muted");
      chipLabel.classList.add("text-accent");
      if (flashTimer) clearTimeout(flashTimer);
      flashTimer = setTimeout(() => {
        chipLabel.classList.remove("text-accent");
        chipLabel.classList.add("text-muted");
      }, 200);
    };

    // throttled live region: 'Section 2 of 4, 38%'
    const announce = () => {
      const fire = () => {
        lastAnnounce = performance.now();
        live.textContent = `Section ${activeIdx + 1} of ${total}, ${Math.round(
          Math.min(100, Math.max(0, needle.t))
        )}%`;
      };
      if (announceTimer) clearTimeout(announceTimer);
      const since = performance.now() - lastAnnounce;
      if (since > 1200) fire();
      else announceTimer = setTimeout(fire, 1200 - since);
    };

    // active section = the one with the most visible pixels in the scroller
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          visiblePx.set(e.target, e.isIntersecting ? e.intersectionRect.height : 0);
        }
        let best = -1;
        let bestH = 0;
        sections.forEach((s, i) => {
          const h = visiblePx.get(s) ?? 0;
          if (h > bestH) {
            bestH = h;
            best = i;
          }
        });
        if (best >= 0 && best !== activeIdx) {
          activeIdx = best;
          flash();
          announce();
          wake();
        }
      },
      { root: scroller, threshold: [0, 0.2, 0.4, 0.6, 0.8, 1] }
    );
    sections.forEach((s) => io.observe(s));

    const ro = new ResizeObserver(() => {
      rebuild();
      wake();
    });
    ro.observe(scroller);

    if (total === 0) chipLabel.textContent = "SEC —";
    wake(); // one primed frame, then immediate sleep until scroll

    return () => {
      cancelAnimationFrame(raf);
      running = false;
      io.disconnect();
      ro.disconnect();
      scroller.removeEventListener("scroll", onScroll);
      if (flashTimer) clearTimeout(flashTimer);
      if (announceTimer) clearTimeout(announceTimer);
    };
  }, [
    sectionSelector,
    jawStiffness,
    jawDamping,
    needleStiffness,
    needleDamping,
  ]);

  return (
    <div ref={rootRef} className={`relative w-full overflow-hidden ${className}`}>
      <div
        ref={scrollerRef}
        tabIndex={0}
        role="region"
        aria-label="scrollable content, measured by caliper"
        className="h-full overflow-y-auto overscroll-contain pr-16 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
      >
        {children ?? <FallbackSections />}
      </div>

      {/* instrument overlay — read-only, decorative; the live region below
          carries the accessible signal */}
      <div aria-hidden className="pointer-events-none absolute inset-0">
        <svg
          ref={svgRef}
          className="absolute inset-y-0 right-0 h-full w-12"
          viewBox={`0 0 ${W} 1`}
          preserveAspectRatio="none"
          fill="none"
        >
          {/* motion-blur echo of the tick scale (opacity/blur driven by
              scroll velocity, removed at rest) */}
          <g ref={echoRef} opacity={0}>
            <path
              ref={echoMinorRef}
              className="text-border"
              stroke="currentColor"
              strokeWidth={1}
            />
            <path
              ref={echoMajorRef}
              className="text-foreground/60"
              stroke="currentColor"
              strokeWidth={1}
            />
          </g>
          {/* main tick scale — translated by -(scrollTop % 8) per frame */}
          <g ref={tickRef}>
            <path
              ref={minorRef}
              className="text-border"
              stroke="currentColor"
              strokeWidth={1}
            />
            <path
              ref={majorRef}
              className="text-foreground/60"
              stroke="currentColor"
              strokeWidth={1}
            />
          </g>
          {/* beam spine */}
          <path
            ref={spineRef}
            className="text-border"
            stroke="currentColor"
            strokeWidth={1.5}
          />
          {/* measured span tint between the jaws */}
          <rect
            ref={regionRef}
            x={2}
            y={0}
            width={W - 4}
            height={0}
            className="text-foreground"
            fill="currentColor"
            opacity={0.05}
          />
          {/* top jaw blade */}
          <g ref={topJawRef} className="text-foreground">
            <path d={`M3 0H${W}`} stroke="currentColor" strokeWidth={1.5} />
            <path d="M3 0V-14" stroke="currentColor" strokeWidth={2.5} />
            <path d="M3 0l12 -5v5z" fill="currentColor" />
          </g>
          {/* bottom jaw blade + vernier subscale */}
          <g ref={botJawRef} className="text-foreground">
            <path d={`M3 0H${W}`} stroke="currentColor" strokeWidth={1.5} />
            <path d="M3 0V14" stroke="currentColor" strokeWidth={2.5} />
            <path d="M3 0l12 5v-5z" fill="currentColor" />
            <path
              d="M30 4h8M30 7.5h8M30 11h8M30 14.5h8"
              stroke="currentColor"
              strokeWidth={1}
              opacity={0.45}
            />
          </g>
          {/* active-value marker — the only accent ink on the instrument */}
          <g ref={markerRef} className="text-accent">
            <path d={`M${W - 22} 0H${W}`} stroke="currentColor" strokeWidth={1.5} />
            <path d={`M${W - 22} 0l-5 -3.5v7z`} fill="currentColor" />
          </g>
        </svg>

        {/* digital readout chip */}
        <div className="absolute bottom-3 right-14 rounded-sm border border-foreground/20 bg-surface px-2.5 py-1.5 font-mono text-[10px] leading-snug shadow-sm">
          <div
            ref={chipLabelRef}
            className="tracking-widest text-muted transition-colors duration-200"
          >
            SEC —/—
          </div>
          <div className="tabular-nums text-foreground">
            <span ref={chipPxRef}>0.0</span>
            <span className="text-muted"> PX · </span>
            <span ref={chipPctRef}>0</span>
            <span className="text-muted">%</span>
          </div>
        </div>
      </div>

      {/* accessible signal — throttled section announcements */}
      <div ref={liveRef} role="status" aria-live="polite" className="sr-only" />
    </div>
  );
}
Use when

a vernier-caliper HUD pinned to a scroll container's edge that measures the active section with a live px/percent readout; use as a scroll-position instrument, not a narrative or a 3D flythrough.

Build spec

A vernier caliper instrument, ~48px wide, pinned to the right edge of a bounded overflow-y scroll container it measures. Built in SVG (tick scale, vernier subscale on the lower jaw, jaw blades, beam spine) plus a DOM font-mono readout chip; every transform is written directly in a rAF loop via refs, never React state. Sections are detected with an IntersectionObserver (root = the scroller) on [data-section] children, active = most visible pixels. The jaws map the active section's top/bottom to container-space y and chase those targets with a spring k=120 s^-2, zeta=0.9 (near-critical, a hair of lag); the readout needle (percent of the section swept past the container center) rides a softer spring k=90 s^-2, zeta=0.55 so it visibly over-settles and wobbles when scrolling stops. Tick scale: minor graduations every 2px (--border ink), majors every 8px and long index lines every 40px (--foreground ink), path d rebuilt only on resize and translated by -(scrollTop % 8) per frame so the scale scrolls with content. Motion blur: a duplicated tick layer offset along the scroll direction with opacity clamp(|v|/3000, 0, 0.6) and blur clamp(|v|/200, 0, 10)/2 px, velocity from per-frame scrollTop delta exponentially smoothed at alpha 0.2, layer removed entirely at rest for crispness. Section change flashes the readout label accent for 200ms and updates a throttled visually-hidden live region ('Section 2 of 4, 38%'); the instrument itself is aria-hidden and read-only, no fake affordances. The scroll listener is passive and only wakes the loop; the loop sleeps when 150ms have passed since the last scroll AND all three springs are inside the settle epsilon (|x-target| < 0.05, |v| < 0.05). All SVG ink is currentColor via token classes (text-border, text-foreground, text-accent only on the active-value marker) so both themes restyle live with zero numeric color reads; the readout chip is bg-surface with border-foreground/20. Guards: zero-height containers skip rebuild, zero-height sections never divide, IntersectionObserver/ResizeObserver/scroll listener/flash and announce timers all torn down on unmount. prefers-reduced-motion: jaws and needle snap instantly, no blur layer, no wobble. The scroll container itself is an explicit tabIndex=0, role="region" element with an aria-label ('scrollable content, measured by caliper'), so it is keyboard-focusable and scrollable cross-browser rather than depending on Chromium's implicit scroll-container focus heuristic (which WebKit/Safari does not ship).

Tags
svgscrollinstrumentspringsectionshudreadoutmeasurement