ns-ui
TOC Minimap Mercury
TOC minimap where scroll progress is a column of liquid mercury: a gooey SVG blob climbs the rail, absorbs section ticks, teardrops under fast scroll, and bulges toward hovered ticks.
Usage
registry/core/toc-minimap-mercury/demo.tsx"use client";
import { MercuryMinimap } from "./component";
const SECTIONS = [
{ id: "intro", label: "00 INTRO" },
{ id: "install", label: "01 INSTALL" },
{ id: "usage", label: "02 USAGE" },
{ id: "theming", label: "03 THEMING" },
{ id: "api", label: "04 API" },
];
const PROPS: [string, string, string][] = [
["sections", "{ id, label }[]", "auto-discovered"],
["selector", "string", '"section[id]"'],
["offset", "number", "0.35"],
["stiffness", "number", "120"],
["damping", "number", "20"],
];
export default function MercuryMinimapDemo() {
return (
<div className="min-h-screen bg-background text-foreground">
<MercuryMinimap sections={SECTIONS} />
<main className="mx-auto max-w-2xl px-6 pb-48 pt-20">
<p className="font-mono text-xs text-ns-muted">ns-ui / toc-minimap-mercury</p>
<section id="intro" className="flex min-h-[85vh] flex-col justify-start pt-10">
<h1 className="text-4xl font-semibold tracking-tight">Mercury Minimap</h1>
<p className="mt-6 max-w-prose leading-relaxed text-ns-muted">
A table of contents where scroll progress behaves like liquid metal. A blob
climbs the rail on the right as you read, gooey-merging with each section
tick it reaches, stretching into a droplet under fast scroll, and bulging
toward whichever tick your cursor approaches.
</p>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Scroll this page slowly, then fling it. Hover the rail on the right to see
the labels slide in and the liquid reach for your pointer. Click any tick
to travel there.
</p>
<div className="mt-8 flex items-center gap-3">
<button
type="button"
onClick={() =>
document.getElementById("api")?.scrollIntoView({ behavior: "smooth" })
}
className="rounded-sm border border-border bg-surface px-4 py-2 font-mono text-xs text-foreground transition-colors hover:border-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
Jump to API
</button>
<a
href="#usage"
className="px-2 py-2 font-mono text-xs text-ns-muted transition-colors hover:text-foreground"
>
See usage
</a>
</div>
</section>
<section id="install" className="flex min-h-[80vh] flex-col justify-start border-t border-border pt-12">
<h2 className="text-2xl font-semibold tracking-tight">Install</h2>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Zero dependencies beyond React. The whole effect is one SVG goo filter and
a spring integrated in a requestAnimationFrame loop that sleeps when the
liquid settles.
</p>
<pre className="mt-6 overflow-x-auto rounded-md border border-border bg-surface p-4 font-mono text-xs leading-relaxed text-ns-muted">
{"npx shadcn add @ns-ui/toc-minimap-mercury"}
</pre>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Everything is direct-DOM: the scroll listener is passive and only writes a
ref, attribute writes happen via setAttribute, and no React state is
touched on the hot path.
</p>
</section>
<section id="usage" className="flex min-h-[80vh] flex-col justify-start border-t border-border pt-12">
<h2 className="text-2xl font-semibold tracking-tight">Usage</h2>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Pass sections explicitly, or mount it bare and it will discover every
{" "}
<span className="font-mono text-xs text-foreground">section[id]</span> on
the page, reading labels from{" "}
<span className="font-mono text-xs text-foreground">data-minimap-label</span>.
</p>
<pre className="mt-6 overflow-x-auto rounded-md border border-border bg-surface p-4 font-mono text-xs leading-relaxed text-ns-muted">
{`<MercuryMinimap
sections={[
{ id: "intro", label: "00 INTRO" },
{ id: "api", label: "04 API" },
]}
/>`}
</pre>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
The blob target is a piecewise-linear map from your scroll position through
the section offsets, so the liquid always agrees with where you actually
are in the document.
</p>
</section>
<section id="theming" className="flex min-h-[80vh] flex-col justify-start border-t border-border pt-12">
<h2 className="text-2xl font-semibold tracking-tight">Theming</h2>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
The liquid fills with{" "}
<span className="font-mono text-xs text-foreground">--color-foreground</span>{" "}
and unreached ticks stroke with{" "}
<span className="font-mono text-xs text-foreground">--color-ns-muted</span>,
falling back to the house values. Monochrome by design: the accent color is
reserved for focus rings only.
</p>
<div className="mt-6 grid grid-cols-2 gap-3">
{[
["foreground", "#ededed"],
["muted", "#8f8f8f"],
].map(([name, hex]) => (
<div
key={name}
className="flex items-center gap-3 rounded-md border border-border bg-surface p-4"
>
<span
aria-hidden
className="h-4 w-4 rounded-full border border-border"
style={{ background: hex }}
/>
<span className="font-mono text-xs text-ns-muted">
{name} {hex}
</span>
</div>
))}
</div>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Under prefers-reduced-motion the goo filter is swapped for a plain 2px
filled line with dots, active states switch instantly, and no animation
frame ever runs.
</p>
</section>
<section id="api" className="flex min-h-[70vh] flex-col justify-start border-t border-border pt-12">
<h2 className="text-2xl font-semibold tracking-tight">API</h2>
<div className="mt-6 overflow-hidden rounded-md border border-border">
{PROPS.map(([name, type, def], i) => (
<div
key={name}
className={`grid grid-cols-3 gap-4 px-4 py-3 font-mono text-xs ${
i > 0 ? "border-t border-border" : ""
}`}
>
<span className="text-foreground">{name}</span>
<span className="text-ns-muted">{type}</span>
<span className="text-ns-muted">{def}</span>
</div>
))}
</div>
<p className="mt-4 max-w-prose leading-relaxed text-ns-muted">
Stiffness and damping tune the spring: 120 / 20 sits just under critical
damping, so a hard scroll stop lands with one small wobble, exactly like a
droplet coming to rest.
</p>
</section>
</main>
</div>
);
}Component
registry/core/toc-minimap-mercury/component.tsx"use client";
import { useEffect, useId, useRef, useState } from "react";
type MinimapSection = { id: string; label: string };
const RAIL_W = 48;
const CX = RAIL_W / 2;
const PAD = 16;
/** Evenly spaced tick centers along a rail of height H. */
function tickPositions(n: number, H: number): number[] {
if (n <= 1) return [H / 2];
return Array.from({ length: n }, (_, i) => PAD + (i * (H - 2 * PAD)) / (n - 1));
}
/** Piecewise-linear map: document read-line position → rail y via section offsets. */
function mapTarget(read: number, tops: number[], ticks: number[]): number {
const n = ticks.length;
const first = ticks[0] ?? 0;
const last = ticks[n - 1] ?? first;
if (n <= 1 || read <= (tops[0] ?? 0)) return first;
if (read >= (tops[n - 1] ?? 0)) return last;
let i = 0;
while (i < n - 2 && read >= (tops[i + 1] ?? Infinity)) i++;
const a = tops[i] ?? 0;
const b = tops[i + 1] ?? a + 1;
const t = (read - a) / Math.max(1, b - a);
return (ticks[i] ?? 0) + t * ((ticks[i + 1] ?? 0) - (ticks[i] ?? 0));
}
// Liquid-mercury TOC minimap: scroll progress is a gooey column that climbs a
// fixed rail, absorbing section ticks as it reaches them. Spring-integrated on
// a direct-DOM rAF loop — no React state on the hot path, loop sleeps when
// settled. Static 2px line + dots under prefers-reduced-motion.
export function MercuryMinimap({
sections,
selector = "section[id]",
offset = 0.35,
stiffness = 120,
damping = 20,
className = "",
}: {
/** explicit section list; omitted → auto-discovered via `selector` (label from data-minimap-label or id) */
sections?: MinimapSection[];
/** querySelectorAll used when `sections` is omitted */
selector?: string;
/** read-line as a fraction of viewport height — a section is "reached" when its top passes this line */
offset?: number;
/** spring stiffness for the blob (px/s² per px of error) */
stiffness?: number;
/** spring damping (slightly under critical → settle wobble) */
damping?: number;
/** extra classes merged onto the rendered root element */
className?: string;
}) {
const [items, setItems] = useState<MinimapSection[] | null>(null);
const [H, setH] = useState(0);
const [reduced, setReduced] = useState(false);
const svgRef = useRef<SVGSVGElement | null>(null);
const wrapRef = useRef<HTMLElement | null>(null);
const colRef = useRef<SVGRectElement | null>(null);
const blobRef = useRef<SVGCircleElement | null>(null);
const helperRef = useRef<SVGCircleElement | null>(null);
const lineRef = useRef<SVGRectElement | null>(null);
const tickRefs = useRef<(SVGCircleElement | null)[]>([]);
const gooTickRefs = useRef<(SVGCircleElement | null)[]>([]);
const dotRefs = useRef<(SVGCircleElement | null)[]>([]);
const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);
// lets the label buttons drive the hover bulge without React state
const hoverCtl = useRef<(i: number) => void>(() => {});
const rawId = useId();
const fid = `goo-${rawId.replace(/[^a-zA-Z0-9-]/g, "")}`;
// discovery + sizing (rare, state is fine here)
useEffect(() => {
setReduced(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
const discover = () =>
sections ??
Array.from(document.querySelectorAll<HTMLElement>(selector)).map((el) => ({
id: el.id,
label: el.dataset.minimapLabel ?? el.id.toUpperCase(),
}));
// guard against unstable `sections` identity (e.g. an inline array literal
// passed by the consumer): only commit a new `items` reference when the
// discovered content actually changed, otherwise the mercury-loop effect
// below (keyed on `items`) would tear down and rebuild every render.
setItems((prev) => {
const next = discover();
if (
prev &&
prev.length === next.length &&
prev.every((p, i) => p.id === next[i]?.id && p.label === next[i]?.label)
) {
return prev;
}
return next;
});
const measure = () => setH(Math.round(window.innerHeight * 0.6));
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [sections, selector]);
// mercury loop — direct setAttribute writes only, sleeps when settled
useEffect(() => {
if (!items || items.length === 0 || !H || reduced) return;
const n = items.length;
const ticks = tickPositions(n, H);
const railTop = ticks[0] ?? PAD;
const els = items.map((s) => document.getElementById(s.id));
let tops = items.map(() => 0);
let vh = window.innerHeight;
const measure = () => {
vh = window.innerHeight;
tops = els.map((el) => (el ? el.getBoundingClientRect().top + window.scrollY : 0));
};
measure();
let scroll = window.scrollY;
let y = mapTarget(scroll + vh * offset, tops, ticks);
let v = 0;
let helperR = 0;
let hover = -1;
let active = -2;
let raf = 0;
let last = 0;
const setActive = (idx: number) => {
if (idx === active) return;
active = idx;
labelRefs.current.forEach((el, i) => {
if (!el) return;
el.classList.toggle("text-foreground", i === idx);
el.classList.toggle("text-ns-muted", i !== idx);
});
};
const frame = (now: number) => {
const dt = Math.min(Math.max((now - last) / 1000, 0.001), 1 / 30);
last = now;
const target = mapTarget(scroll + vh * offset, tops, ticks);
// semi-implicit Euler spring — underdamped, so the settle wobble is free
v += (stiffness * (target - y) - damping * v) * dt;
y += v * dt;
// volume-preserving droplet stretch, oriented along travel. A small
// constant bias keeps the blob a teardrop (not a plain circle) even at
// rest, so the liquid-mercury mechanic reads in a still frame too.
const sY = 1.06 + Math.min(Math.abs(v) * 0.004, 0.74);
const sX = 1 / Math.sqrt(sY);
const lead = Math.sign(v) * (sY - 1) * 4;
blobRef.current?.setAttribute(
"transform",
`translate(${CX} ${y + lead}) scale(${sX} ${sY})`
);
colRef.current?.setAttribute("height", `${Math.max(0, y - railTop)}`);
// absorption: crossing a tick pulls it into the goo group; the filter
// renders the merge, scrolling back up snaps the neck and releases it
let reach = 0;
for (let i = 0; i < n; i++) {
const inGoo = y >= (ticks[i] ?? 0) - 6;
if (inGoo) reach = i;
const goo = gooTickRefs.current[i];
if (goo) goo.style.visibility = inGoo ? "visible" : "hidden";
const outline = tickRefs.current[i];
if (outline) outline.style.opacity = inGoo ? "0" : "1";
}
setActive(reach);
// hover bulge: helper circle grows 0→5 at 60% of the way toward the tick
const hTarget = hover >= 0 ? 5 : 0;
helperR += (hTarget - helperR) * Math.min(1, dt * 16);
const helper = helperRef.current;
if (helper) {
if (hover >= 0) {
const ty = ticks[hover] ?? railTop;
const colY = Math.min(Math.max(ty, railTop), y);
helper.setAttribute("cy", `${colY + 0.6 * (ty - colY)}`);
}
helper.setAttribute("r", `${Math.max(0, helperR)}`);
}
const settled =
Math.abs(v) < 2 && Math.abs(target - y) < 0.5 && Math.abs(hTarget - helperR) < 0.05;
raf = settled ? 0 : requestAnimationFrame(frame);
};
const wake = () => {
if (!raf) {
last = performance.now();
raf = requestAnimationFrame(frame);
}
};
const onScroll = () => {
scroll = window.scrollY;
wake();
};
const onResize = () => {
measure();
scroll = window.scrollY;
wake();
};
const onMove = (e: PointerEvent) => {
const svg = svgRef.current;
if (!svg) return;
const py = e.clientY - svg.getBoundingClientRect().top;
let best = -1;
let bd = 24;
for (let i = 0; i < n; i++) {
const d = Math.abs(py - (ticks[i] ?? 0));
if (d < bd) {
bd = d;
best = i;
}
}
if (best !== hover) {
hover = best;
wake();
}
};
const onLeave = () => {
hover = -1;
wake();
};
hoverCtl.current = (i: number) => {
hover = i;
wake();
};
const wrap = wrapRef.current;
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onResize);
wrap?.addEventListener("pointermove", onMove);
wrap?.addEventListener("pointerleave", onLeave);
wake();
return () => {
cancelAnimationFrame(raf);
hoverCtl.current = () => {};
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
wrap?.removeEventListener("pointermove", onMove);
wrap?.removeEventListener("pointerleave", onLeave);
};
}, [items, H, reduced, offset, stiffness, damping]);
// reduced motion: plain filled line + dots, instant states, no rAF
useEffect(() => {
if (!items || items.length === 0 || !H || !reduced) return;
const n = items.length;
const ticks = tickPositions(n, H);
const railTop = ticks[0] ?? PAD;
const els = items.map((s) => document.getElementById(s.id));
let tops = items.map(() => 0);
const measure = () => {
tops = els.map((el) => (el ? el.getBoundingClientRect().top + window.scrollY : 0));
};
measure();
const apply = () => {
const t = mapTarget(window.scrollY + window.innerHeight * offset, tops, ticks);
lineRef.current?.setAttribute("height", `${Math.max(0, t - railTop)}`);
let reach = 0;
for (let i = 0; i < n; i++) {
const on = t >= (ticks[i] ?? 0) - 6;
if (on) reach = i;
const dot = dotRefs.current[i];
if (dot) {
dot.setAttribute("fill", on ? "var(--foreground)" : "none");
dot.setAttribute(
"stroke",
on ? "var(--foreground)" : "var(--ns-muted)"
);
}
}
labelRefs.current.forEach((el, i) => {
if (!el) return;
el.classList.toggle("text-foreground", i === reach);
el.classList.toggle("text-ns-muted", i !== reach);
});
};
const onResize = () => {
measure();
apply();
};
apply();
window.addEventListener("scroll", apply, { passive: true });
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", apply);
window.removeEventListener("resize", onResize);
};
}, [items, H, reduced, offset]);
if (!items || items.length === 0 || !H) return null;
const ticks = tickPositions(items.length, H);
const railTop = ticks[0] ?? PAD;
return (
<nav
ref={wrapRef}
aria-label="Section minimap"
className={`group fixed right-4 top-1/2 z-40 -translate-y-1/2 ${className}`}
>
{items.map((s, i) => (
<button
key={s.id}
type="button"
aria-label={`Jump to ${s.label}`}
onClick={() =>
document
.getElementById(s.id)
?.scrollIntoView({ behavior: reduced ? "auto" : "smooth" })
}
onFocus={() => hoverCtl.current(i)}
onBlur={() => hoverCtl.current(-1)}
className="absolute right-0 flex h-8 -translate-y-1/2 cursor-pointer items-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
style={{ top: ticks[i] ?? 0, paddingRight: RAIL_W }}
>
<span
ref={(el) => {
labelRefs.current[i] = el;
}}
className="-translate-x-2 whitespace-nowrap pr-2 font-mono text-xs text-ns-muted opacity-0 transition-[transform,opacity] duration-150 ease-out group-focus-within:translate-x-0 group-focus-within:opacity-100 group-hover:translate-x-0 group-hover:opacity-100 motion-reduce:transition-none"
>
{s.label}
</span>
</button>
))}
{reduced ? (
<svg width={RAIL_W} height={H} viewBox={`0 0 ${RAIL_W} ${H}`} aria-hidden className="block">
<rect
ref={lineRef}
x={CX - 1}
y={railTop}
width={2}
height={0}
fill="var(--foreground)"
/>
{items.map((s, i) => (
<circle
key={s.id}
ref={(el) => {
dotRefs.current[i] = el;
}}
cx={CX}
cy={ticks[i] ?? 0}
r={4}
fill="none"
stroke="var(--ns-muted)"
strokeWidth={1.5}
/>
))}
</svg>
) : (
<svg
ref={svgRef}
width={RAIL_W}
height={H}
viewBox={`0 0 ${RAIL_W} ${H}`}
aria-hidden
className="block overflow-visible"
>
<defs>
<filter
id={fid}
x="-100%"
y="-20%"
width="300%"
height="140%"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur" />
<feColorMatrix
in="blur"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -9"
/>
</filter>
</defs>
{/* outline ticks — outside the goo so they stay crisp until absorbed */}
{items.map((s, i) => (
<circle
key={s.id}
ref={(el) => {
tickRefs.current[i] = el;
}}
cx={CX}
cy={ticks[i] ?? 0}
r={4}
fill="none"
stroke="var(--ns-muted)"
strokeWidth={1.5}
style={{ transition: "opacity 150ms ease-out" }}
/>
))}
{/* liquid layer — everything in here gooey-merges */}
<g filter={`url(#${fid})`} fill="var(--foreground)">
{/* 8px wide: survives the blur-4 → ×19−9 alpha threshold (4px would vanish) */}
<rect ref={colRef} x={CX - 4} y={railTop} width={8} height={0} rx={4} />
{items.map((s, i) => (
<circle
key={s.id}
ref={(el) => {
gooTickRefs.current[i] = el;
}}
cx={CX}
cy={ticks[i] ?? 0}
r={6}
style={{ visibility: "hidden" }}
/>
))}
<circle ref={helperRef} cx={CX} cy={railTop} r={0} />
<circle ref={blobRef} r={7} transform={`translate(${CX} ${railTop})`} />
</g>
</svg>
)}
</nav>
);
}The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/toc-minimap-mercury.tsx, so point the import there.
Use when a scroll-position TOC/minimap where a gooey mercury blob climbs a rail and absorbs section ticks as you scroll; use to show progress through a long document, not for magnifying items in an icon row.
Install
npx shadcn add https://design.helpmarq.com /r/toc-minimap-mercury.jsonBuild spec
Build a fixed-right table-of-contents minimap where scroll progress renders as liquid mercury inside a 48px-wide, 60vh-tall SVG rail. Use one goo filter (feGaussianBlur stdDeviation 4 into an feColorMatrix with alpha row 0 0 0 19 -9) over a liquid group filled with the foreground token: a 4px rounded-rect column from rail top to the blob, a leading r=7 blob circle, and per-section r=4 circles that start hidden; outside the filter, draw crisp unfilled r=4 ticks stroked with the muted token at each section's evenly mapped y. Map window scroll through the sections' document offsets piecewise-linearly to a target y, and drive the blob with a semi-implicit Euler spring (stiffness 120, damping 20, slightly underdamped so stops land with a droplet wobble) integrated in a requestAnimationFrame loop that writes only via setAttribute, reads scroll from a ref set by a passive listener, and sleeps when velocity and error settle. Stretch the blob volume-preservingly by velocity — scaleY = 1.06 + clamp(|v|*0.004, 0, 0.74), scaleX = 1/sqrt(scaleY) — shifted a few px toward the travel direction, with a small constant bias baked into the 1.06 floor so the blob still reads as a teardrop (not a plain circle) at rest, not just while moving; fast scroll further stretches the leading edge. When the blob passes within 6px of a tick, reveal that tick's hidden circle inside the goo group (the filter renders the merge) and fade the outline tick; scrolling back up re-hides it so the goo neck snaps and releases the dot. On pointer within 24px of a tick, grow a helper circle in the goo group from r 0 to 5 positioned 60% of the way from the column toward the tick so the liquid bulges at the cursor; clicking a tick smooth-scrolls its section into view. Geist Mono text-xs labels slide in from translateX(-8px) to 0 over 150ms ease-out on rail hover, muted by default and foreground when active. Under prefers-reduced-motion swap the whole goo layer for a plain 2px filled line plus dots with instant active states and no animation loop. Auto-discover section[id] elements when no sections prop is given.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sections? | MinimapSection[] | — | explicit section list; omitted → auto-discovered via `selector` (label from data-minimap-label or id) |
| selector? | string | "section[id]" | querySelectorAll used when `sections` is omitted |
| offset? | number | 0.35 | read-line as a fraction of viewport height — a section is "reached" when its top passes this line |
| stiffness? | number | 120 | spring stiffness for the blob (px/s² per px of error) |
| damping? | number | 20 | spring damping (slightly under critical → settle wobble) |
| className? | string | — | extra classes merged onto the rendered root element |