ns-ui
Chain Scale
A map zoom control that is the scale bar: drag its end handle in log space to zoom, and the bar's length always reads a sayable 1-2-5 round distance, snapping with a 180ms width spring at every ladder crossing instead of drawing an arbitrary raw number.
Usage
registry/core/chain-scale/demo.tsx"use client";
import { useState } from "react";
import { ChainScale } from "./component";
// a mock map viewport whose aria-describedby is wired to the control's own
// live scale sentence — the pattern a real map integration would follow.
export default function ChainScaleDemo() {
const [mpp, setMpp] = useState(1);
const descId = "chain-scale-metro-desc";
return (
<main className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-md">
<p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
ns-ui / chain-scale
</p>
<h1 className="text-lg font-semibold text-foreground">
The zoom control is the scale bar
</h1>
<p className="mt-1 text-sm leading-relaxed text-ns-muted">
Grab the handle at the bar's end and stretch it — the map zooms
so the printed distance is always a round, sayable number. Between
denominations the bar keeps growing under your pointer; at each
crossing it springs to the new fraction of its width and the
checkerboard re-subdivides.
</p>
<div
role="region"
aria-label="Sample map"
aria-describedby={descId}
className="relative mt-5 flex h-56 items-center justify-center overflow-hidden rounded-md border border-border bg-background"
style={{
backgroundImage:
"linear-gradient(var(--border) 1px, transparent 1px), linear-gradient(90deg, var(--border) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
>
<span className="font-mono text-[11px] text-ns-muted">
1 px = {mpp < 1 ? `${(mpp * 100).toFixed(0)} cm` : `${mpp.toFixed(mpp < 10 ? 1 : 0)} m`} on the ground
</span>
<div className="absolute bottom-3 left-3">
<ChainScale
id="chain-scale-metro"
label="Map scale"
defaultMetersPerPixel={1}
minMetersPerPixel={0.05}
maxMetersPerPixel={5000}
maxWidth={180}
onValueChange={setMpp}
/>
</div>
</div>
<p className="mt-3 font-mono text-[11px] text-ns-muted">
drag the handle or scroll the wheel over it to zoom · arrows step
one round denomination · double-click resets to 3/4 width
</p>
</div>
</main>
);
}Component
registry/core/chain-scale/component.tsx"use client";
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// ChainScale — a map zoom control that IS the scale bar. One governing
// scalar, metersPerPixel, drives everything: the bar's rendered length is
// denom/metersPerPixel, where denom is the LARGEST value from the 1-2-5
// preferred series (…100 200 500 1000…) whose length still fits maxWidth.
// The printed distance is therefore always a sayable round number, never the
// raw drag value — because the formula is continuous in metersPerPixel while
// the ladder is discrete, the bar keeps growing under the pointer with its
// OLD label still attached, right up until the next rung overtakes it, at
// which instant the denomination jumps and the bar visibly snaps to the new
// fraction of maxWidth. That snap is the one thing worth animating: a 180ms
// ease-out-expo width spring plus a 1px tick flash mark the detent, while
// every other frame — ordinary dragging within one denomination — tracks
// the pointer with zero added lag. Dragging maps handle dx to
// metersPerPixel in LOG space (linear would make a city-to-street zoom take
// a hundred screens of drag). Wheel over the control walks the same log
// mapping; arrow keys step exactly one ladder rung (an inherent detent);
// double-click resets to the nearest rung where the bar sits at 3/4 of
// maxWidth. Only the animated pixel geometry (bar width, handle x, end
// label x) is ever written imperatively via refs/rAF; block COUNT and fill
// pattern are ordinary React state, since they only change at rung
// granularity — so a crossing shows the new block count immediately while
// the container that holds it is still springing to size. DOM + CSS only,
// no canvas; every ink value is a CSS custom property, never a literal.
// ---------------------------------------------------------------------------
const MANTISSAS = [1, 2, 5] as const;
const MIN_EXP = -2; // 0.01 m floor denomination
const MAX_EXP = 7; // 10,000,000 m ceiling denomination
const SPRING_MS = 180;
const WHEEL_LOG_STEP = 0.12;
const DRAG_LOG_PER_PX = 0.01;
const BAR_H = 10;
const HANDLE_W = 10;
const LABEL_ROW_TOP = BAR_H + 6;
interface Rung {
v: number; // denomination, in metres
mant: 1 | 2 | 5;
}
function buildLadder(): Rung[] {
const out: Rung[] = [];
for (let e = MIN_EXP; e <= MAX_EXP; e++) {
for (const mant of MANTISSAS) {
out.push({ v: Number((mant * Math.pow(10, e)).toPrecision(6)), mant });
}
}
return out;
}
const LADDER = buildLadder();
function blocksForMant(mant: 1 | 2 | 5): number {
return mant === 1 ? 2 : mant;
}
function formatShort(v: number): string {
if (v < 1) return `${Math.round(v * 100)} cm`;
if (v < 1000) return `${v} m`;
return `${v / 1000} km`;
}
function formatWords(v: number): string {
if (v < 1) return `${Math.round(v * 100)} centimetres`;
if (v < 1000) return `${v} metres`;
return `${v / 1000} kilometres`;
}
function formatPerPixel(m: number): string {
if (m < 1) return `${(m * 100).toFixed(0)} cm`;
if (m < 10) return `${m.toFixed(1)} m`;
return `${Math.round(m)} m`;
}
// largest rung whose bar (rung.v / m) still fits maxWidthPx — the whole
// falsifiable contract in one function.
function pickIndex(m: number, maxWidthPx: number): number {
let idx = 0;
for (let i = 0; i < LADDER.length; i++) {
if (LADDER[i].v / m <= maxWidthPx) idx = i;
else break;
}
return idx;
}
function easeOutExpo(t: number): number {
return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
export interface ChainScaleProps {
/** controlled metres-per-pixel; omit for uncontrolled */
metersPerPixel?: number;
/** uncontrolled initial metres-per-pixel */
defaultMetersPerPixel?: number;
/** most-zoomed-in bound (smallest metersPerPixel) */
minMetersPerPixel?: number;
/** most-zoomed-out bound (largest metersPerPixel) */
maxMetersPerPixel?: number;
/** pixel budget the bar is never allowed to exceed */
maxWidth?: number;
/** accessible name */
label?: string;
/** stable id; the live description renders at `${id}-desc` so a host map
* region can point its own aria-describedby at it */
id?: string;
/** fires with the new metres-per-pixel on every change — wire this to a map's zoom */
onValueChange?: (metersPerPixel: number) => void;
/** extra classes merged onto the root element */
className?: string;
}
export function ChainScale({
metersPerPixel,
defaultMetersPerPixel = 1,
minMetersPerPixel = 0.02,
maxMetersPerPixel = 20000,
maxWidth = 200,
label = "Map scale",
id,
onValueChange,
className = "",
}: ChainScaleProps) {
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const descId = id ? `${id}-desc` : `chain-scale-desc-${uid}`;
const clamp = (v: number) =>
Math.min(maxMetersPerPixel, Math.max(minMetersPerPixel, v));
const isControlled = metersPerPixel !== undefined;
const [internal, setInternal] = useState(() => clamp(defaultMetersPerPixel));
const m = isControlled ? clamp(metersPerPixel as number) : internal;
const mRef = useRef(m);
mRef.current = m;
const commitRef = useRef<(v: number) => void>(() => {});
commitRef.current = (v: number) => {
const c = clamp(v);
if (!isControlled) setInternal(c);
onValueChange?.(c);
};
const index = useMemo(() => pickIndex(m, maxWidth), [m, maxWidth]);
// pickIndex(m, .) is monotonic in m, so the reachable index band is just
// the two clamp bounds run through it — the full LADDER is padded far
// past what min/maxMetersPerPixel can ever select, so those raw bounds
// would advertise a range the slider can't actually reach.
const minIndex = useMemo(
() => pickIndex(minMetersPerPixel, maxWidth),
[minMetersPerPixel, maxWidth]
);
const maxIndex = useMemo(
() => pickIndex(maxMetersPerPixel, maxWidth),
[maxMetersPerPixel, maxWidth]
);
const rung = LADDER[index];
const blocks = blocksForMant(rung.mant);
const shortLabel = formatShort(rung.v);
const wordsLabel = formatWords(rung.v);
const perPixel = formatPerPixel(m);
const sentence = `Scale: ${wordsLabel}. 1 pixel equals ${perPixel}.`;
const valueText = `${shortLabel} — 1px = ${perPixel}`;
const trackRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const endLabelRef = useRef<HTMLDivElement>(null);
const tickRef = useRef<HTMLDivElement>(null);
const indexRef = useRef(index);
const engineRef = useRef<{
applyFromM: (mv: number) => void;
dragStart: (clientX: number) => void;
dragMove: (clientX: number) => void;
dragEnd: () => void;
} | null>(null);
// -- imperative geometry engine: direct DOM writes, refs only ------------
useLayoutEffect(() => {
const track = trackRef.current;
const bar = barRef.current;
const handle = handleRef.current;
const endLabel = endLabelRef.current;
const tick = tickRef.current;
if (!track || !bar || !handle || !endLabel || !tick) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let visWidth = Math.min(
maxWidth,
LADDER[indexRef.current].v / mRef.current
);
let targetWidth = visWidth;
let raf = 0;
let tweenFrom = visWidth;
let tweenStart = 0;
let springing = false;
let dragging = false;
let dragStartClientX = 0;
let dragStartLogM = 0;
const place = (px: number) => {
visWidth = px;
bar.style.width = `${px}px`;
handle.style.transform = `translate3d(${(px - HANDLE_W / 2).toFixed(2)}px,0,0)`;
endLabel.style.transform = `translate3d(${px.toFixed(2)}px,0,0) translateX(-100%)`;
};
place(visWidth);
const flashTick = (px: number) => {
tick.style.left = `${px.toFixed(2)}px`;
tick.style.animation = "none";
void tick.offsetWidth; // force reflow so re-adding the class restarts it
tick.style.animation = "";
};
const loop = (now: number) => {
raf = 0;
const t = Math.min(1, (now - tweenStart) / SPRING_MS);
place(tweenFrom + (targetWidth - tweenFrom) * easeOutExpo(t));
if (t < 1) raf = requestAnimationFrame(loop);
else springing = false;
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(loop);
};
const applyFromM = (mv: number) => {
const idx = pickIndex(mv, maxWidth);
const crossed = idx !== indexRef.current;
indexRef.current = idx;
const target = Math.min(maxWidth, LADDER[idx].v / mv);
targetWidth = target;
if (reduced) {
cancelAnimationFrame(raf);
raf = 0;
springing = false;
place(target);
return;
}
if (crossed) {
tweenFrom = visWidth;
tweenStart = performance.now();
springing = true;
flashTick(target);
wake();
} else if (!springing) {
place(target);
}
};
const dragMove = (clientX: number) => {
if (!dragging) return;
const dx = clientX - dragStartClientX;
const nextM = clamp(Math.exp(dragStartLogM - dx * DRAG_LOG_PER_PX));
applyFromM(nextM);
commitRef.current(nextM);
};
const dragStart = (clientX: number) => {
dragging = true;
dragStartClientX = clientX;
dragStartLogM = Math.log(mRef.current);
};
const dragEnd = () => {
dragging = false;
};
// React binds `wheel` passively on the root, so a synthetic onWheel
// prop can never preventDefault (Chrome logs and the page scrolls
// under the drag) — bind a real non-passive listener here instead.
const onWheelNative = (e: WheelEvent) => {
e.preventDefault();
const dir = e.deltaY > 0 ? 1 : -1; // scroll down -> zoom out
const nextM = clamp(
Math.exp(Math.log(mRef.current) + dir * WHEEL_LOG_STEP)
);
applyFromM(nextM);
commitRef.current(nextM);
};
track.addEventListener("wheel", onWheelNative, { passive: false });
engineRef.current = { applyFromM, dragStart, dragMove, dragEnd };
return () => {
cancelAnimationFrame(raf);
track.removeEventListener("wheel", onWheelNative);
engineRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxWidth, minMetersPerPixel, maxMetersPerPixel]);
useLayoutEffect(() => {
engineRef.current?.applyFromM(m);
}, [m]);
const onKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowRight":
case "ArrowUp": {
e.preventDefault();
const nextIndex = Math.min(LADDER.length - 1, index + 1);
// *(1+1e-9): landing exactly on v/maxWidth is a round-trip
// division that pickIndex re-derives from m — floating-point
// noise can land it one ULP over and read back the rung BELOW,
// so the arrow silently no-ops at certain widths. Nudge just
// inside the rung; ladder steps are all >=1.6x apart so this
// never bleeds into a neighbor.
const nextM = clamp((LADDER[nextIndex].v / maxWidth) * (1 + 1e-9));
if (nextM !== mRef.current) commitRef.current(nextM);
return;
}
case "ArrowLeft":
case "ArrowDown": {
e.preventDefault();
const nextIndex = Math.max(0, index - 1);
const nextM = clamp((LADDER[nextIndex].v / maxWidth) * (1 + 1e-9));
if (nextM !== mRef.current) commitRef.current(nextM);
return;
}
case "Home":
// jump to the min/max SCALE (the props), not the full 0.01m-10Mm
// ladder — the ladder is padded far past what min/maxMetersPerPixel
// can ever reach, so indexing into it directly would advertise and
// land on a rung the control can never otherwise show.
e.preventDefault();
if (minMetersPerPixel !== mRef.current)
commitRef.current(minMetersPerPixel);
return;
case "End":
e.preventDefault();
if (maxMetersPerPixel !== mRef.current)
commitRef.current(maxMetersPerPixel);
return;
default:
return;
}
};
const onDoubleClick = () => {
const nextM = clamp(rung.v / (0.75 * maxWidth));
commitRef.current(nextM);
};
const onPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
trackRef.current?.setPointerCapture(e.pointerId);
trackRef.current?.focus({ preventScroll: true });
engineRef.current?.dragStart(e.clientX);
};
const onPointerMove = (e: React.PointerEvent) => {
engineRef.current?.dragMove(e.clientX);
};
const onPointerUp = () => engineRef.current?.dragEnd();
const onPointerCancel = () => engineRef.current?.dragEnd();
return (
<div
className={`inline-flex flex-col items-start gap-2 rounded-md border border-border bg-background p-3 font-mono ${className}`}
>
<style>{`
.chain-scale-slider:hover .chain-scale-handle,
.chain-scale-slider:focus-visible .chain-scale-handle,
.chain-scale-slider:active .chain-scale-handle {
background-color: var(--ns-accent);
border-color: var(--ns-accent);
}
.chain-scale-handle {
transition: background-color 150ms ease-out, border-color 150ms ease-out;
}
.chain-scale-tick {
animation: chain-scale-tick-flash 380ms ease-out;
}
@keyframes chain-scale-tick-flash {
0% { opacity: 1; }
100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.chain-scale-tick { animation: none !important; opacity: 0 !important; }
.chain-scale-handle { transition: none !important; }
}
`}</style>
<span className="select-none text-[10px] uppercase tracking-[0.2em] text-ns-muted">
{label}
</span>
<div
ref={trackRef}
role="slider"
tabIndex={0}
aria-label={label}
aria-orientation="horizontal"
aria-valuemin={minIndex}
aria-valuemax={maxIndex}
aria-valuenow={index}
aria-valuetext={valueText}
aria-describedby={descId}
data-chain-scale-slider
onKeyDown={onKeyDown}
onDoubleClick={onDoubleClick}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
className="chain-scale-slider relative block cursor-ew-resize touch-none select-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
style={{ width: maxWidth, height: LABEL_ROW_TOP + 14 }}
>
{/* checkerboard bar — presentational; the wrapper above carries the value */}
<div
ref={barRef}
aria-hidden
className="absolute left-0 top-0 flex overflow-hidden rounded-[2px] border border-border"
style={{ height: BAR_H }}
>
{Array.from({ length: blocks }).map((_, i) => (
<div
key={i}
className="h-full flex-1"
style={{
backgroundColor: i % 2 === 0 ? "var(--foreground)" : "transparent",
borderRight: i < blocks - 1 ? "1px solid var(--border)" : "none",
}}
/>
))}
</div>
{/* detent tick — flashes on each ladder crossing, positioned by the engine */}
<div
ref={tickRef}
aria-hidden
className="chain-scale-tick absolute w-px bg-foreground opacity-0"
style={{ top: -2, height: BAR_H + 4 }}
/>
{/* "0" — the only numeral besides the end label; no interior numerals */}
<span
aria-hidden
className="absolute select-none text-[10px] text-ns-muted"
style={{ left: 0, top: LABEL_ROW_TOP }}
>
0
</span>
{/* end label — tracks the animated bar edge via the engine */}
<div
ref={endLabelRef}
aria-hidden
className="absolute select-none whitespace-nowrap text-[10px] text-foreground"
style={{ top: LABEL_ROW_TOP }}
>
{shortLabel}
</div>
{/* grab handle — the only element allowed --ns-accent, and only on interaction */}
<div
ref={handleRef}
aria-hidden
className="chain-scale-handle absolute rounded-full border border-foreground bg-background"
style={{ top: -2, width: HANDLE_W, height: BAR_H + 4 }}
/>
</div>
<p id={descId} aria-live="polite" aria-atomic="true" className="sr-only">
{sentence}
</p>
</div>
);
}The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/chain-scale.tsx, so point the import there.
Use when the zoom control on an embedded map, floorplan, or coverage view, where the control itself should always answer 'how far is that' — one scalar (metersPerPixel) drives both the zoom and a checkerboard scale bar whose printed distance is always a round 1-2-5 number, the bar's own pixel length breathing to fit around it; pick slider-vernier instead for an ordinary bounded numeric input read off a fixed-length caliper scale, where there is no map, no zoom, and the track's length never changes.
Install
npx shadcn add https://design.helpmarq.com /r/chain-scale.jsonBuild spec
Build a scale bar that doubles as the zoom control, governed by one scalar, metersPerPixel (m). At any m, the bar's rendered length is denom/m, where denom is the LARGEST value from the 1-2-5 preferred series (…0.5 1 2 5 10 20 50 100 200 500 1000…, spanning roughly 1cm to 10,000km) whose length still fits a maxWidth prop — pickIndex(m, maxWidth) walks the ascending ladder and keeps the last rung whose v/m <= maxWidth. This is the falsifiable contract: the printed distance is ALWAYS a 1-2-5 round number, never the raw drag value. Because the formula is continuous in m while the ladder is discrete, holding denom fixed and changing m makes the bar's pixel length grow or shrink continuously (following the pointer with zero added lag), right up until the next rung overtakes it — at that instant denom jumps and the bar visibly snaps to the new fraction of maxWidth. Animate exactly that snap and nothing else: on a ladder crossing, ease the bar's width from its pre-crossing pixel length to the new target over 180ms ease-out-expo (a cubic tween written directly to refs via rAF, restarting from whatever the visual width currently is if a new crossing arrives mid-tween), flash a 1px --foreground tick at the new edge as the detent mark, and slide the round-number label out/in. Within a single denomination, width/handle/label positions are set directly via refs with NO easing — only React re-renders for the checkerboard's block COUNT and fill pattern, which are ordinary state driven by the denomination's leading digit (mantissa 1 -> 2 blocks, 2 -> 2 blocks, 5 -> 5 blocks), alternating --foreground fill and transparent, 1px --border rules between them, no interior numerals — only '0' at the left and the current denomination's label at the right, in Geist Mono. Never let React's render-time style props touch the animated width/x values (that fight is what makes the spring flash and this is the exact anti-pattern to avoid); the wrapper div carrying role=slider is the ONLY place the drag/keyboard/wheel handlers live, and the checkerboard/tick/label/handle beneath it are all aria-hidden presentation. INTERACTION: pointerdown on the wrapper starts a drag that maps handle dx to metersPerPixel in LOG space (m = clamp(exp(startLogM - dx*k))) — never linear, or a city-to-street zoom would take a hundred screens of drag; wheel over the wrapper walks the same log mapping one WHEEL_LOG_STEP per notch; ArrowLeft/Right/Up/Down step exactly one ladder rung by setting m so the new rung sits at exactly maxWidth (an inherent detent, not a quantized continuous value); Home/End jump to the ladder's min/max rung; double-click resets m so the CURRENT rung's bar sits at exactly 3/4 of maxWidth. ACCESSIBILITY: the wrapper is role=slider tabIndex=0 with aria-valuemin/max as the ladder's index bounds, aria-valuenow the current rung's index, and aria-valuetext combining the denomination and the raw per-pixel reading (e.g. '500 m — 1px = 3.2 m'); aria-describedby points at a visually-hidden aria-live=polite sentence ('Scale: 500 metres. 1 pixel equals 3.2 metres.') rendered at a stable `${id}-desc` id so a host map region's own aria-describedby can reference the same live text; prefers-reduced-motion cancels the width spring and tick flash entirely, snapping straight to the new geometry. Zero dependencies, DOM + CSS only — no canvas, no SVG required for the geometry (plain divs), no hex or Tailwind palette colors anywhere in markup or inline style.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| metersPerPixel? | number | — | controlled metres-per-pixel; omit for uncontrolled |
| defaultMetersPerPixel? | number | 1 | uncontrolled initial metres-per-pixel |
| minMetersPerPixel? | number | 0.02 | most-zoomed-in bound (smallest metersPerPixel) |
| maxMetersPerPixel? | number | 20000 | most-zoomed-out bound (largest metersPerPixel) |
| maxWidth? | number | 200 | pixel budget the bar is never allowed to exceed |
| label? | string | "Map scale" | accessible name |
| id? | string | — | stable id; the live description renders at `${id}-desc` so a host map region can point its own aria-describedby at it |
| onValueChange? | (metersPerPixel: number) => void | — | fires with the new metres-per-pixel on every change — wire this to a map's zoom |
| className? | string | — | extra classes merged onto the root element |