Skip to main content

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.

Usage

registry/core/growth-ring/demo.tsx
"use client";

import { GrowthRing, type GrowthRingVersion } from "./component";

// Newest first, like a git log — GrowthRing reverses this internally so
// radius still grows outward with time. Deltas span the full 2-14px clamp:
// v10's +2200 is the "one giant paste" the clamp exists for, v8's +12 sits
// at the floor. Authors hand off three times (v7->v8, v9->v10, v12->v13),
// each a latewood line in the ring chart.
const VERSIONS: GrowthRingVersion[] = [
  { id: "v14", label: "v14", author: "Priya Shah", delta: 340, deltaLabel: "+340 words", time: "14:02" },
  { id: "v13", label: "v13", author: "Priya Shah", delta: 58, deltaLabel: "+58 words", time: "12:47" },
  { id: "v12", label: "v12", author: "Marco Diaz", delta: 210, deltaLabel: "-210 words", time: "11:20" },
  { id: "v11", label: "v11", author: "Marco Diaz", delta: 140, deltaLabel: "+140 words", time: "10:05" },
  { id: "v10", label: "v10", author: "Marco Diaz", delta: 2200, deltaLabel: "+2200 words", time: "09:40" },
  { id: "v9", label: "v9", author: "Jae Lin", delta: 76, deltaLabel: "+76 words", time: "Yesterday 17:12" },
  { id: "v8", label: "v8", author: "Jae Lin", delta: 12, deltaLabel: "+12 words", time: "Yesterday 16:50" },
  { id: "v7", label: "v7", author: "Priya Shah", delta: 300, deltaLabel: "+300 words", time: "Mon 09:15" },
  { id: "v6", label: "v6", author: "Priya Shah", delta: 40, deltaLabel: "+40 words", time: "Mon 08:40" },
];

export default function GrowthRingDemo() {
  return (
    <main className="flex min-h-screen justify-center bg-background px-6 py-16">
      <div className="w-full max-w-2xl">
        <p className="mb-8 font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / growth-ring — version history &amp; restore
        </p>

        <section className="rounded-md border border-border bg-surface/40 p-5">
          <h2 className="text-sm font-medium text-foreground">Draft: Q3 renewal deck</h2>
          <p className="mt-1 text-sm leading-relaxed text-ns-muted">
            Nine saves across three authors. Pick an older version to preview a restore — the
            rings outside it hinge open and dim before anything is discarded.
          </p>
          <div className="mt-5">
            <GrowthRing
              versions={VERSIONS}
              onRestore={(v) => console.log("growth-ring restored to", v.label)}
            />
          </div>
        </section>
      </div>
    </main>
  );
}

Component

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>
  );
}

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

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 scroll-story-strata instead when the strata metaphor is for scroll position or content depth rather than a restorable version history, and toast-gravity-stack for accumulated content depth with no restore action at all.

Install

npx shadcn add https://design.helpmarq.com/r/growth-ring.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

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

PropTypeDefaultDescription
versionsGrowthRingVersion[]—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——