Skip to main content

ns-ui

Scroll Caliper

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.

Usage

registry/core/scroll-caliper/demo.tsx
"use client";

import { ScrollCaliper } from "./component";

const SECTIONS: {
  id: string;
  kicker: string;
  title: string;
  body: string[];
  code?: string;
}[] = [
  {
    id: "cal-principle",
    kicker: "01",
    title: "The measuring principle",
    body: [
      "A vernier caliper does not measure distance so much as it brackets it: two ground blades close on the workpiece until the gap between them is the dimension. The instrument in the margin does the same to this article — the upper jaw rides the active section's leading edge, the lower jaw its trailing edge, and the opening between them is the section's visible extent in pixels.",
      "Scroll slowly and the blades track almost imperceptibly. Flick the wheel and they lag by design: the jaws chase their targets through a near-critically damped spring, the small hesitation that separates an instrument from a cursor.",
    ],
  },
  {
    id: "cal-vernier",
    kicker: "02",
    title: "Reading the vernier",
    body: [
      "The main beam carries a fixed scale — minor graduations every two pixels, majors every eight, a long index line every forty. Against it slides the vernier subscale on the lower jaw, its pitch deliberately mismatched so that exactly one pair of lines coincides at any opening. That coincidence is the fractional digit a plain ruler cannot give you.",
      "Here the coincidence is done for you: the readout chip resolves the jaw opening to a tenth of a pixel and the sweep percentage of the section under measurement. Watch it when you stop scrolling — the needle overshoots and settles with a wobble, a softer spring than the jaws ride.",
    ],
  },
  {
    id: "cal-motion",
    kicker: "03",
    title: "Velocity and blur",
    body: [
      "Move fast enough and any engraved scale smears. The tick layer is duplicated, offset along the scroll direction, and blurred in proportion to smoothed scroll velocity — then removed entirely at rest so the graduations stay razor-crisp for reading.",
    ],
    code: `// motion blur, per frame
opacity = clamp(|v| / 3000, 0, 0.6)
length  = clamp(|v| /  200, 0, 10)  // px
offset  = sign(v) * length

// settle gate — loop sleeps when
idle    = now - lastScroll > 150ms
settled = |x - target| < 0.05 && |v| < 0.05`,
  },
  {
    id: "cal-care",
    kicker: "04",
    title: "Care and calibration",
    body: [
      "A caliper is only as honest as its zero. Close the jaws on nothing and the readout must say nothing: scroll to a section boundary and the blades meet the edges exactly, spring lag bleeding off within a few hundred milliseconds. Keep the beam clean — no decorative ink lives on the scale, and the single accent marker is reserved for the value being read.",
      "Store the instrument closed. When the article stops moving, the loop stops running; an idle caliper spends no frames at all.",
    ],
  },
];

export default function ScrollCaliperDemo() {
  const jump = (id: string) => {
    document
      .getElementById(id)
      ?.scrollIntoView({ behavior: "smooth", block: "start" });
  };

  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-3xl">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / scroll-caliper
        </p>
        <div className="overflow-hidden rounded-md border border-border bg-surface">
          <header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-5 py-3">
            <span className="font-mono text-xs tracking-widest text-ns-muted">
              FIELD MANUAL — VERNIER MEASUREMENT
            </span>
            <nav className="flex items-center gap-1.5">
              {SECTIONS.map((s) => (
                <button
                  key={s.id}
                  type="button"
                  onClick={() => jump(s.id)}
                  className="rounded-sm border border-border px-2.5 py-1 font-mono text-[11px] tabular-nums text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  {s.kicker}
                </button>
              ))}
            </nav>
          </header>

          <ScrollCaliper className="h-[520px]">
            <article className="space-y-14 px-8 py-10">
              {SECTIONS.map((s) => (
                <section
                  key={s.id}
                  id={s.id}
                  data-section
                  className="scroll-mt-6 space-y-4"
                >
                  <p className="font-mono text-[11px] tracking-widest text-ns-muted">
                    {s.kicker}
                  </p>
                  <h2 className="text-xl font-semibold tracking-tight text-foreground">
                    {s.title}
                  </h2>
                  {s.body.map((p, i) => (
                    <p key={i} className="text-sm leading-relaxed text-ns-muted">
                      {p}
                    </p>
                  ))}
                  {s.code ? (
                    <pre className="overflow-x-auto rounded-sm border border-border bg-background p-4 font-mono text-xs leading-relaxed text-foreground">
                      {s.code}
                    </pre>
                  ) : null}
                </section>
              ))}
              <p className="border-t border-border pt-6 font-mono text-[11px] text-ns-muted">
                END OF MANUAL — the jaws should now be closed on section 04
              </p>
            </article>
          </ScrollCaliper>
        </div>
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          scroll the article, or jump between sections — stop to catch the
          needle wobble
        </p>
      </div>
    </main>
  );
}

Component

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-ns-muted">{copy}</p>
          <p className="text-sm leading-relaxed text-ns-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-ns-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;
  /** extra classes merged onto the rendered root element */
  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-ns-muted");
      chipLabel.classList.add("text-ns-accent");
      if (flashTimer) clearTimeout(flashTimer);
      flashTimer = setTimeout(() => {
        chipLabel.classList.remove("text-ns-accent");
        chipLabel.classList.add("text-ns-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-ns-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-ns-muted transition-colors duration-200"
          >
            SEC —/—
          </div>
          <div className="tabular-nums text-foreground">
            <span ref={chipPxRef}>0.0</span>
            <span className="text-ns-muted"> PX · </span>
            <span ref={chipPctRef}>0</span>
            <span className="text-ns-muted">%</span>
          </div>
        </div>
      </div>

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

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/scroll-caliper.tsx, so point the import there.

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; pick toc-minimap-mercury instead when the readout should be a table of contents the reader can click, minimap-pantograph when a small drag should mechanically drive a large pan, or scroll-story-strata when the scroll is a narrative rather than a measurement.

Install

npx shadcn add https://design.helpmarq.com/r/scroll-caliper.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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

Props

PropTypeDefaultDescription
children?ReactNode—scrollable content; sections are matched via sectionSelector
sectionSelector?string"[data-section]"selector for measurable sections inside the scroller
jawStiffness?number120jaw spring stiffness in s^-2 (near-critical chase)
jawDamping?number0.9jaw damping ratio (1 = critical)
needleStiffness?number90readout needle stiffness in s^-2 (softer, visible over-settle)
needleDamping?number0.55readout needle damping ratio
className?string"h-[480px]"extra classes merged onto the rendered root element