ns-ui
Growth Ring
Version history as a dendrochronological cross-section: one ring per save, ring width proportional to change size, a dark line wherever authorship changed hands, and restoring an older version previews the discard as outer rings hinging open before you confirm.
Use when the version-history-and-restore panel for a shared doc, where the ring chart's own geometry has to carry three facts at once — chronological order (radius, strictly monotonic, never re-sorted by author), each save's change size (ring width, clamped 2-14px) and exactly where authorship handed off (a dark latewood line) — and restoring an older version previews precisely what gets cut before it's cut. Pick core-sample-scroll instead when the strata metaphor is for scroll position or content depth rather than a restorable version history, and sediment-stack for accumulated content depth with no restore action at all.
Install
npx shadcn add https://design.helpmarq.com /r/growth-ring.jsonSource
registry/core/growth-ring/component.tsx"use client";
import { useId, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// GrowthRing — version history as a dendrochronological cross-section. The
// authoritative control is the listbox on the left: every fact (`v14 ·
// Priya Shah · +340 words · 14:02 · Restore`) lives there as real text, and
// keyboard/click drive it exactly like any other listbox. The SVG cross-
// section beside it is pure `aria-hidden` decoration that mirrors the same
// data geometrically — radius is the version's position in time (index,
// strictly monotonic; never re-sorted by author), ring width is the
// version's change magnitude clamped 2-14px, and a tight dark "latewood"
// line marks every boundary where authorship changed hands. Restoring is
// modeled as a literal cut: arming a version tilts every ring OUTSIDE it
// open on a shared hinge and dims them, so what a restore discards is shown
// before it happens; confirming is a distinct second step.
// ---------------------------------------------------------------------------
export type GrowthRingVersion = {
id: string;
/** short version tag, e.g. "v14" */
label: string;
author: string;
/** magnitude of the change this version made — the scalar ring width derives from */
delta: number;
/** pre-formatted display string for the delta, e.g. "+340 words" */
deltaLabel: string;
/** pre-formatted display string for when it saved, e.g. "14:02" */
time: string;
};
export interface GrowthRingProps {
/** newest first, like a git log — the ring chart reads it in reverse (oldest at the core) */
versions: GrowthRingVersion[];
/** called once a restore is actually confirmed, with the version restored to */
onRestore?: (version: GrowthRingVersion) => void;
/** accessible name for the listbox; default "Version history" */
ariaLabel?: string;
className?: string;
}
const INNER_RADIUS = 20;
const MIN_RING_WIDTH = 2;
const MAX_RING_WIDTH = 14;
// A raw character/word delta of a few thousand (one giant paste) would
// otherwise dwarf every other ring and flatten the chart to "one huge band
// plus noise" — dividing before the clamp is what keeps normal edits in a
// readable 2-14px band while still clamping the outlier rather than trusting
// the divisor alone.
const DELTA_TO_PX = 25;
const PADDING_MIN = 14;
// The discard flap tilts open about its own bottom tangent via a 3D
// rotateX fold (see the "discarded" group below), not a flat in-plane
// rotate — a flat rotate of a full closed ring just produces a second,
// off-center ring, which is the actual bug this was chasing. The fold's
// foreshortening still pushes the far edge outward a bit. A fixed pixel
// padding runs out once the ring stack gets big enough — a proportional
// floor keeps the swing inside the viewBox no matter how many versions or
// how wide their rings get.
const HINGE_CLEARANCE_RATIO = 0.2;
const HINGE_DEG = 8;
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, n));
}
function initials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function plural(n: number, word: string) {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
// A near-full circular arc, used only as an invisible <textPath> guide for
// the focused ring's author-initials label — never rendered with a stroke.
function arcGuide(cx: number, cy: number, r: number) {
return `M ${cx} ${cy - r} A ${r} ${r} 0 1 1 ${cx - 0.01} ${cy - r}`;
}
type Ring = {
id: string;
version: GrowthRingVersion;
chronoIndex: number; // 0 = oldest
inner: number;
outer: number;
mid: number;
width: number;
handover: boolean; // author differs from the previous (older) version
};
function buildRings(versions: GrowthRingVersion[]): Ring[] {
const chrono = [...versions].reverse(); // oldest first — radius follows time, nothing else
let r = INNER_RADIUS;
return chrono.map((v, i) => {
const width = clamp(Math.round(Math.abs(v.delta) / DELTA_TO_PX), MIN_RING_WIDTH, MAX_RING_WIDTH);
const inner = r;
const outer = r + width;
r = outer;
const handover = i > 0 && chrono[i - 1].author !== v.author;
return { id: v.id, version: v, chronoIndex: i, inner, outer, mid: (inner + outer) / 2, width, handover };
});
}
export function GrowthRing({
versions,
onRestore,
ariaLabel = "Version history",
className = "",
}: GrowthRingProps) {
const uid = useId();
const [items, setItems] = useState(versions);
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const [focusedId, setFocusedId] = useState<string | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [armedId, setArmedId] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const optionRefs = useRef(new Map<string, HTMLLIElement>());
const rings = useMemo(() => buildRings(items), [items]);
const outerMost = rings.length ? rings[rings.length - 1].outer : INNER_RADIUS;
const padding = Math.max(PADDING_MIN, Math.round(outerMost * HINGE_CLEARANCE_RATIO));
const size = outerMost + padding;
const cx = size;
const cy = size;
const hingeX = cx;
const hingeY = cy + outerMost; // bottom tangent of the whole disc
const armedRing = armedId ? rings.find((r) => r.id === armedId) ?? null : null;
const discardedRings = armedRing ? rings.filter((r) => r.chronoIndex > armedRing.chronoIndex) : [];
const keptRings = armedRing ? rings.filter((r) => r.chronoIndex <= armedRing.chronoIndex) : rings;
const discardedAuthors = new Set(discardedRings.map((r) => r.version.author));
const discardCount = discardedRings.length;
function arm(id: string) {
const idx = items.findIndex((v) => v.id === id);
if (idx <= 0) return; // the newest version is already current — nothing to preview or restore
setArmedId(id);
const target = items[idx];
const authorsAhead = new Set(items.slice(0, idx).map((v) => v.author));
setAnnouncement(
`Restoring to ${target.label} removes ${plural(idx, "newer version")} by ${plural(authorsAhead.size, "author")}.`
);
}
function disarm(announce: boolean) {
setArmedId(null);
if (announce) setAnnouncement("Restore cancelled.");
}
function commit() {
if (!armedRing) return;
const idx = items.findIndex((v) => v.id === armedRing.id);
if (idx < 0) return;
const restored = items[idx];
const next = items.slice(idx); // the cut: everything newer than the restored version is gone
setItems(next);
setArmedId(null);
setActiveId(restored.id);
setAnnouncement(`Restored to ${restored.label}.`);
onRestore?.(restored);
}
function focusOption(id: string) {
setActiveId(id);
optionRefs.current.get(id)?.focus();
}
function handleOptionKeyDown(e: React.KeyboardEvent<HTMLLIElement>, id: string) {
const idx = items.findIndex((v) => v.id === id);
if (e.key === "ArrowDown") {
e.preventDefault();
const next = items[Math.min(idx + 1, items.length - 1)];
if (next) focusOption(next.id);
} else if (e.key === "ArrowUp") {
e.preventDefault();
const prev = items[Math.max(idx - 1, 0)];
if (prev) focusOption(prev.id);
} else if (e.key === "Home") {
e.preventDefault();
if (items[0]) focusOption(items[0].id);
} else if (e.key === "End") {
e.preventDefault();
const last = items[items.length - 1];
if (last) focusOption(last.id);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
arm(id);
} else if (e.key === "Escape" && armedId) {
e.preventDefault();
disarm(true);
}
}
function ringStroke(chronoIndex: number) {
return chronoIndex % 2 === 0 ? "var(--ns-muted)" : "var(--background)";
}
function renderRingGroup(group: Ring[], isCutFace: boolean) {
return group.map((ring) => {
const pulsed = ring.id === focusedId || ring.id === hoveredId;
const strokeWidth = pulsed ? ring.width + 3 : ring.width;
const showLabel = ring.id === focusedId;
const guideId = `${uid}-arc-${ring.chronoIndex}`;
return (
<g key={ring.id}>
<circle
cx={cx}
cy={cy}
r={ring.mid}
fill="none"
stroke={ringStroke(ring.chronoIndex)}
strokeWidth={strokeWidth}
className="ns-growth-ring"
/>
<circle
cx={cx}
cy={cy}
r={ring.inner}
fill="none"
stroke={ring.handover ? "var(--foreground)" : "var(--border)"}
strokeWidth={ring.handover ? 1.6 : 1}
/>
{showLabel ? (
<>
<path id={guideId} d={arcGuide(cx, cy, ring.mid)} fill="none" stroke="none" />
<text
className="font-mono"
fontSize={Math.max(7, Math.min(9, ring.width))}
fill="var(--foreground)"
>
<textPath href={`#${guideId}`} startOffset="2%">
{initials(ring.version.author)}
</textPath>
</text>
</>
) : null}
</g>
);
});
}
return (
<div data-growth-root className={className}>
<style>{`
.ns-growth-ring{transition:stroke-width 200ms cubic-bezier(0.16,1,0.3,1)}
.ns-growth-outer{transition:transform 420ms cubic-bezier(0.16,1,0.3,1),opacity 420ms cubic-bezier(0.16,1,0.3,1)}
.ns-growth-option{transition:background-color 160ms ease,border-color 160ms ease}
@media (prefers-reduced-motion: reduce){
.ns-growth-ring{transition:none}
.ns-growth-outer{transition:opacity 150ms linear !important;transform:none !important}
.ns-growth-option{transition:none}
}
`}</style>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start">
<ul
role="listbox"
aria-label={ariaLabel}
className="min-w-0 flex-1 divide-y divide-border rounded-md border border-border"
>
{items.map((v, idx) => {
const isArmed = v.id === armedId;
const isActive = v.id === activeId;
const isFocused = v.id === focusedId;
return (
<li
key={v.id}
ref={(el) => {
if (el) optionRefs.current.set(v.id, el);
else optionRefs.current.delete(v.id);
}}
role="option"
aria-selected={isArmed}
tabIndex={isActive ? 0 : -1}
data-growth-version={v.id}
onFocus={() => setFocusedId(v.id)}
onBlur={() => setFocusedId((f) => (f === v.id ? null : f))}
onMouseEnter={() => setHoveredId(v.id)}
onMouseLeave={() => setHoveredId((h) => (h === v.id ? null : h))}
onClick={() => {
setActiveId(v.id);
arm(v.id);
}}
onKeyDown={(e) => handleOptionKeyDown(e, v.id)}
className={
"ns-growth-option flex cursor-pointer flex-wrap items-baseline gap-x-2.5 gap-y-0.5 px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent " +
(isFocused
? "border-l-2 border-ns-accent bg-surface pl-[10px]"
: "border-l-2 border-transparent pl-[10px]") +
(isArmed ? " bg-surface" : "")
}
>
<span className="font-mono text-foreground">{v.label}</span>
<span className="text-foreground">{v.author}</span>
<span className="font-mono tabular-nums text-ns-muted">{v.deltaLabel}</span>
<span className="font-mono tabular-nums text-ns-muted">{v.time}</span>
<span className="ml-auto shrink-0 rounded-full border border-border px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide text-ns-muted">
{idx === 0 ? "Current" : "Restore"}
</span>
</li>
);
})}
</ul>
<svg
aria-hidden="true"
focusable="false"
viewBox={`0 0 ${size * 2} ${size * 2}`}
className="h-48 w-48 shrink-0 sm:h-56 sm:w-56"
>
<g>{renderRingGroup(keptRings, false)}</g>
{armedRing ? (
<circle
cx={cx}
cy={cy}
r={armedRing.outer}
fill="none"
stroke="var(--foreground)"
strokeWidth={2}
/>
) : (
<circle cx={cx} cy={cy} r={outerMost} fill="none" stroke="var(--border)" strokeWidth={1} />
)}
{discardedRings.length ? (
<g
data-discarded="true"
className="ns-growth-outer"
style={{
transformOrigin: `${hingeX}px ${hingeY}px`,
// A flat in-plane rotate() here was the actual bug: these
// are full closed rings, and rotating a closed circle about
// a point on its own rim in 2D just produces a second,
// same-radius circle with a *different center* — it reads
// as "misaligned," not "hinged open," because there's no
// such thing as a partial-arc 2D rotation of a full ring
// that stays concentric. A hinge that lifts away from the
// viewer needs the Z axis: rotateX about the hinge line
// keeps the near (bottom) edge anchored to the kept rings
// and tilts the far edge back/up with real foreshortening,
// which is what "opening like a lid" actually looks like.
transform: `perspective(560px) rotateX(-${HINGE_DEG}deg)`,
transformStyle: "preserve-3d",
opacity: 0.4,
}}
>
{renderRingGroup(discardedRings, true)}
</g>
) : null}
</svg>
</div>
{armedRing ? (
<div data-growth-confirm-panel className="mt-4 rounded-md border border-border bg-surface p-3">
<p className="text-sm text-foreground">
This removes {plural(discardCount, "newer version")} by{" "}
{plural(discardedAuthors.size, "author")}.
</p>
<div className="mt-2.5 flex gap-2">
<button
type="button"
onClick={() => disarm(true)}
className="rounded-sm border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-background focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
Cancel
</button>
<button
type="button"
data-growth-confirm
onClick={commit}
className="rounded-sm bg-ns-accent px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
>
Confirm restore to {armedRing.version.label}
</button>
</div>
</div>
) : null}
<p role="status" aria-live="polite" className="sr-only">
{announcement}
</p>
</div>
);
}
Build spec
GrowthRing pairs an authoritative listbox of versions with a decorative SVG cross-section that mirrors the same data as concentric rings. Props: `versions` (`{id,label,author,delta,deltaLabel,time}[]`, newest first — the component reverses this internally so the oldest save sits at the core and radius always grows outward with time, matching the physical read of tree rings), `onRestore` (fires once a restore is actually confirmed, with the restored version), `ariaLabel`, `className`. The listbox (`role=listbox`) is the only interactive and only authoritative surface: each `role=option` renders its facts as real text in document order — label, author, delta, timestamp, then 'Current' on the newest row or 'Restore' on every older one — so an option's accessible name alone reads e.g. 'v9, Jae Lin, +76 words, Yesterday 17:12, Restore'. Roving tabindex drives keyboard nav (Up/Down/Home/End move the tabbable option and DOM focus together); Enter, Space, or a click on any option except the newest arms that version as a restore candidate. The SVG beside the list is `aria-hidden` end to end and derives everything from one governing scalar per version — its index in time. Radius is that index's cumulative position (strictly monotonic; grouping or sorting rings by author is never done, because position-equals-date is the entire point of the metaphor). Ring width is `clamp(|delta| / 25, 2, 14)`px, so an ordinary edit reads as a thin band and a giant paste still clamps to the same 14px ceiling instead of swallowing the rest of the chart. Adjacent rings alternate `--ns-muted`/`--background` fill with a 1px `--border` boundary circle between them; wherever the boundary's two neighboring versions have different authors, that boundary renders instead as a tight `--foreground` line (1.6px) — a latewood mark, readable independently of the alternating fill. The option currently holding real DOM focus gets a `--ns-accent` left rail on its row (the component's one non-button use of that token) and its matching ring gets an author-initials label in Geist Mono set along an invisible circular guide path; hovering *or* focusing a row pulses its ring by increasing `stroke-width` alone, on a 200ms transition, with no color, scale, or opacity change. Arming a version (everything but the newest) is the restore preview: an announcement text and Cancel/Confirm panel appear, the armed version's own outer edge is redrawn as a 2px `--foreground` cut line, and every ring chronologically after it — the versions a restore would discard — is regrouped into one `<g>` that folds open 8deg around a shared hinge line at the bottom of the disc — a 3D `perspective()/rotateX()` fold, not a flat 2D `rotate()`, because rotating a full closed ring in-plane just produces a second, off-center ring rather than a hinged lid — and drops to 40% opacity, so the discard is shown as a physical flap opening before anything is lost. Re-arming (clicking a different older row) just re-targets the same preview; Cancel or Escape drops it with no side effect. Confirm restore — the only other `--ns-accent` surface in the component, and a genuine second step — truncates the version list down to the armed version and everything older, calls `onRestore`, and returns to the resting, unarmed cross-section with the restored version now on top. Every arm/cancel/confirm transition writes a plain-language sentence ('Restoring to v10 removes 4 newer versions by 2 authors.', 'Restore cancelled.', 'Restored to v10.') into a single `role=status`/`aria-live=polite` region, so the destructive scope is announced in words, not left to the geometry. Under `prefers-reduced-motion: reduce` the hinge rotation is dropped entirely (the discarded rings' transform stays `none`) and only the opacity dim plays, and the hover/focus stroke-width pulse loses its transition and applies instantly — both still fully legible. No canvas, no dependency; every color used anywhere in the component is one of `--background`, `--foreground`, `--ns-muted`, `--border`, or `--ns-accent`.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| versions | GrowthRingVersion[] | — | newest first, like a git log — the ring chart reads it in reverse (oldest at the core) |
| onRestore? | (version: GrowthRingVersion) => void | — | called once a restore is actually confirmed, with the version restored to |
| ariaLabel? | string | "Version history" | accessible name for the listbox; default "Version history" |
| className? | string | — | — |