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.
npx shadcn add https://design.helpmarq.com /r/toc-minimap-mercury.jsonregistry/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;
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-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(--color-foreground, #ededed)" : "none");
dot.setAttribute(
"stroke",
on ? "var(--color-foreground, #ededed)" : "var(--color-muted, #8f8f8f)"
);
}
}
labelRefs.current.forEach((el, i) => {
if (!el) return;
el.classList.toggle("text-foreground", i === reach);
el.classList.toggle("text-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-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-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(--color-foreground, #ededed)"
/>
{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(--color-muted, #8f8f8f)"
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(--color-muted, #8f8f8f)"
strokeWidth={1.5}
style={{ transition: "opacity 150ms ease-out" }}
/>
))}
{/* liquid layer — everything in here gooey-merges */}
<g filter={`url(#${fid})`} fill="var(--color-foreground, #ededed)">
{/* 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>
);
}
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.
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.