ns-ui / loud
Background Text Branch Canopy
A full-bleed ambient background where an L-system tree grows entirely out of readable text: every limb is one run of words drawn with a single fillText, pivoting on its own foot as it angles from its parent, with the outer canopy senescing and regrowing forever so it never settles into a finished frame.
Use when a full-bleed backdrop where the branching structure itself is made of legible words, not glyphs standing in for a scalar field (that's background-ascii-plasma/hero-ascii-*) and not a textured stroke (that's tendril-cast, auxin-canal, tree-root-trace, cambium-lay) — pick this when the point is typography given a botanical body, with real word runs as the geometric primitive.
Install
npx shadcn add https://design.helpmarq.com /r/background-text-branch-canopy.jsonSource
registry/loud/background-text-branch-canopy/component.tsx"use client";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
// ---------------------------------------------------------------------------
// BackgroundTextBranchCanopy — a full-bleed ambient background where TEXT IS
// THE LIMB, not a texture on one. An L-system tree grows in Canvas 2D, but
// every branch is drawn with exactly one `ctx.fillText()` call after
// `translate(foot)` + `rotate(limbAngle)` — a run of readable words from
// `text`, windowed to that limb's length, set as a rigid body pivoting on
// its own foot (the joint it grows from) as it angles away from its parent.
// There is no per-character grid and no glyph-per-cell loop anywhere in this
// file: the primitive is a run of legible words placed as geometry, not a
// raster stand-in for luminance.
//
// WHY THIS IS NOT A RESTYLE OF EITHER NEIGHBORING FAMILY —
// vs. background-ascii-plasma / hero-ascii-* / text-ascii-cascade: those
// quantize a scalar field onto a FIXED CHARACTER GRID, one glyph per cell,
// chosen by density/luminance — the character is a stand-in pixel and
// reads as meaningless. Nothing here is gridded and no character is chosen
// by density; the alphabet is real words from `text`, unbroken, upright,
// and spaced exactly as typed.
// vs. tendril-cast / auxin-canal / tree-root-trace / cambium-lay: those
// grow STROKES — an SVG `<path>` (tendril-cast, tree-root-trace) or a
// filled ring boundary (cambium-lay) that is textured or colored
// afterward. This grows TEXT STRIPS: the geometric primitive painted per
// limb is `fillText`, never a `stroke()`/path `d` at all. A stroke can be
// drawn at any width without changing its meaning; a limb here stops
// existing as a limb the moment its word run would clip below a few
// readable characters (see LIMB BUDGET) — the text isn't dressing on a
// line, the text's own legibility is the constraint that shapes the tree.
//
// LIMB BUDGET, DERIVED FROM READABILITY, NOT THE REVERSE. Trunk length
// L0 = 0.28 * min(container w, h); each generation's length is L0 * 0.72^d.
// Type size is picked ONCE from the container (9-15px, clamped from
// min-dimension * 0.022) and never shrinks with depth — recession is
// expressed as fewer words and lower alpha, never smaller glyphs, because a
// depth ramp that shrinks type is exactly the ASCII family's "raster
// stand-in" failure mode reintroduced through the back door. A generation
// stops recursing once its own length can no longer hold ~4 monospace
// characters (measured, not guessed) — that per-size arithmetic is the
// entire depth ceiling; see the readme block above `buildTopology`.
//
// PIVOT ON THE FOOT: draw-time places `ctx.translate` at the limb's OWN
// foot (where it grows from its parent's tip), rotates to the limb's
// absolute angle, and fillText's from local (0,0) — the run is anchored at
// the joint and extends toward the tip, upright. Any limb whose absolute
// angle points generally leftward (cos < 0) is drawn from `angle + PI` with
// `textAlign: "right"` instead of `"left"` — same physical span from the
// same foot, still upright, never mirrored/upside-down text.
//
// ALIVE FOREVER, TWO STRUCTURAL CHANNELS, NOT A GROW-ONCE-AND-STOP TIMER:
// (1) Turnover. Only the outer canopy (depth >= CHURN_DEPTH) ages and
// senesces: each churning limb owns its own randomized lifespan: on
// expiry it eases its length down to a stub over SENESCE_MS while its
// alpha fades further, then regrows from the same foot with a fresh
// word offset and a new lifespan. The trunk and first branch level
// never senesce, so the crown's skeleton holds while its outer growth
// continually sheds and replaces itself — this is what keeps the
// canopy from ever reaching a single finished, static frame.
// (2) Sway. Every limb's absolute angle carries `swayAmp(depth) *
// sin(t*swayFreq(depth) + phase)` on top of its angle from its
// parent, and because the tree is walked parent-before-child every
// frame, a child's absolute angle already includes its parent's sway
// — the wobble composes down the chain instead of jittering
// independently per limb, which is what reads as one breathing crown
// instead of noise.
// Both are on unconditionally, not gated behind hover/pointer — this is a
// resting-state background.
//
// CONTENT PROTECTION: the canopy is deliberately origin-biased toward the
// right two-thirds of the frame (trunk foot sits right-of-center, growing
// generally straight up with symmetric spread), and a CSS scrim (a
// `--background`-tinted linear gradient, strongest at the bottom edge) sits
// above the canvas so a headline/CTA docked bottom-LEFT — the zone the demo
// actually uses — has a real reading surface under it rather than fighting a
// full crown of words. Trunk bias alone is not enough (the crown still
// reaches the lower third at deep containers), so both are required
// together. (Previously biased upper-LEFT, which planted the trunk foot
// directly inside the bottom-left content block's own bounding box — the
// bias existed but pointed at the wrong corner; a worst-case-spread
// simulation at 1440x900 confirmed the corrected bias keeps every limb's
// ink to the right of the content block with margin to spare.)
//
// TOKENS: --foreground for the inner ~40% of depth (near, heaviest),
// --ns-muted for the rest (receding), with alpha itself also stepping down
// by depth on top of that — so the near/far read holds by VALUE in both
// themes without any luminance comparison or hue math, the same guarantee
// dye-whorl/weld-pool get from a luminance ramp, bought here for free by
// just compositing a token color at lower alpha over `--background`.
// `--ns-accent` never appears — this is a resting background, not
// interaction chrome. Canvas 2D fillStyle cannot take a raw `var(--x)`
// string, so both tokens are read via getComputedStyle at mount and
// re-read on a MutationObserver watching documentElement's class, same as
// every other canvas-hosted showpiece in this registry.
//
// PERFORMANCE: default hero-sized container (~1200x800) settles at ~60-65
// limbs (2 primary trunks off one stub, capped generation depth 5-6 by the
// readability rule above); a large full-bleed container can grow to the
// hard ceiling of 150 limbs (LIMB_BUDGET). 150 `fillText` calls/frame is
// negligible next to the clear+composite of a full-bleed canvas, which is
// the actual per-frame cost here — DPR is capped at 2 (1.5 blurs 10px mono
// past legibility), 0.6 inside an autoplay card (registry
// convention, `[data-autoplay-root]`), and the render loop pauses on
// `visibilitychange` and via `IntersectionObserver` when scrolled offscreen.
//
// SMALLEST READABLE SIZE: at a 320px min-dimension (L0=90px, font 9px,
// cellW ~5.4px), a first-generation limb (length ~65px) still holds a full
// short word plus a partial second word (~12 chars) — readable. By the
// third generation (length ~34px) a limb holds one short word only; the
// component simply stops generating a fourth generation at that size
// (MIN_LIMB_PX gate) rather than shipping unreadable text, so the crown is
// shallower, not smaller-lettered, on small containers.
// ---------------------------------------------------------------------------
interface Limb {
parent: number; // -1 for the trunk stub
depth: number;
relAngle: number; // angle contributed relative to the parent's absolute angle (trunk: absolute)
lenFull: number; // fully-grown length in px
churns: boolean; // true once depth >= CHURN_DEPTH — only churning limbs senesce/regrow
swayAmp: number;
swayFreq: number;
swayPhase: number;
wordStart: number;
lifespan: number;
// live simulation state
state: "growing" | "mature" | "senescing";
growProgress: number; // 0..1, eased length fraction
age: number; // ms since last (re)birth, mature-phase clock
senesceT: number; // 0..1 progress through the senescence ease
// per-frame computed geometry (written during the draw-time tree walk)
footX: number;
footY: number;
tipX: number;
tipY: number;
absAngle: number;
}
const GROW_MS = 2200; // ms for one limb to ease from stub to full length
const CHILD_START_FRAC = 0.55; // a limb's children begin growing once it is this far along — the natural stagger, no separate timer needed
const CHURN_DEPTH = 2; // trunk + first branch level (depth 0-1) never senesce, keeping the crown's skeleton stable
const SENESCE_MS = 1500;
const SHRINK_FLOOR = 0.22; // a senescing limb eases down to this fraction of full length, never to zero (a visible stub, not a pop)
const LIFESPAN_MIN = 7000;
const LIFESPAN_MAX = 15000;
const RATIO = 0.72; // per-generation length ratio
const HARD_MAX_DEPTH = 6;
const MIN_RUN_CHARS = 4; // a generation stops once its length can't hold this many monospace chars
const LIMB_BUDGET = 150; // hard ceiling on total limbs regardless of container size
const STEP_MS = 1000 / 30; // fixed 30Hz simulation tick, matches the registry's other growth components
const PREWARM_MS = 9000; // fast-forwarded synchronously before first paint so t=0 shows a full crown, not a bare stub (tendril-cast's PREWARM_TICKS pattern)
const REDUCED_FREEZE_MS = 16000; // fast-forwarded for prefers-reduced-motion: past first full maturation and into steady churn, so the frozen frame shows varied limb depths/ages, not a single freshly-completed tree
const MAX_REDUCED_TICKS = 6000;
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function makeLimb(
parent: number,
depth: number,
relAngle: number,
lenFull: number,
rand: () => number,
wordCount: number
): Limb {
const churns = depth >= CHURN_DEPTH;
return {
parent,
depth,
relAngle,
lenFull,
churns,
swayAmp: Math.min(0.09, 0.015 + depth * 0.01),
swayFreq: 0.00015 + depth * 0.00004 + rand() * 0.00006,
swayPhase: rand() * Math.PI * 2,
wordStart: wordCount > 0 ? Math.floor(rand() * wordCount) : 0,
lifespan: LIFESPAN_MIN + rand() * (LIFESPAN_MAX - LIFESPAN_MIN),
state: "growing",
growProgress: 0,
age: 0,
senesceT: 0,
footX: 0,
footY: 0,
tipX: 0,
tipY: 0,
absAngle: 0,
};
}
// Builds the fixed topology (parent links, relative angles, full lengths) for
// a container of the given size. Depth ceiling is derived from readability —
// a generation is only added once its length can hold MIN_RUN_CHARS at the
// container's own type size — not picked as an arbitrary constant, per the
// file header. Two primary trunks off one short stub give the crown width to
// actually fill a wide frame instead of reading as a single spike.
function buildTopology(w: number, h: number, cellW: number, rand: () => number, wordCount: number): Limb[] {
const minDim = Math.min(w, h);
const L0 = Math.max(24, minDim * 0.28);
const minLimbPx = cellW * MIN_RUN_CHARS;
const limbs: Limb[] = [];
const stub = makeLimb(-1, 0, -Math.PI / 2, L0 * 0.4, rand, wordCount);
limbs.push(stub);
const spawn = (parentIdx: number, depth: number, relAngle: number, lenFull: number) => {
if (limbs.length >= LIMB_BUDGET) return;
if (depth > HARD_MAX_DEPTH) return;
const idx = limbs.length;
limbs.push(makeLimb(parentIdx, depth, relAngle, lenFull, rand, wordCount));
const nextLen = lenFull * RATIO;
if (nextLen < minLimbPx || depth >= HARD_MAX_DEPTH) return;
const spread = 0.34 + depth * 0.05 + rand() * 0.08;
const jitter = () => (rand() - 0.5) * 0.12;
spawn(idx, depth + 1, -spread + jitter(), nextLen);
spawn(idx, depth + 1, spread + jitter(), nextLen);
};
const primaryLen = L0 * RATIO;
if (primaryLen >= minLimbPx) {
spawn(0, 1, -0.3 + (rand() - 0.5) * 0.1, primaryLen);
spawn(0, 1, 0.3 + (rand() - 0.5) * 0.1, primaryLen);
}
return limbs;
}
function rebirth(limb: Limb, rand: () => number, wordCount: number): void {
limb.state = "growing";
limb.growProgress = 0;
limb.age = 0;
limb.senesceT = 0;
limb.wordStart = wordCount > 0 ? Math.floor(rand() * wordCount) : 0;
limb.lifespan = LIFESPAN_MIN + rand() * (LIFESPAN_MAX - LIFESPAN_MIN);
}
function tick(limb: Limb, dt: number, rand: () => number, wordCount: number, parentReady: boolean): void {
if (!parentReady) return; // children wait for the parent to clear CHILD_START_FRAC — see the walk in draw()
switch (limb.state) {
case "growing": {
limb.growProgress = Math.min(1, limb.growProgress + dt / GROW_MS);
if (limb.growProgress >= 1) {
limb.state = "mature";
limb.age = 0;
}
return;
}
case "mature": {
limb.age += dt;
if (limb.churns && limb.age > limb.lifespan) {
limb.state = "senescing";
limb.senesceT = 0;
}
return;
}
case "senescing": {
limb.senesceT = Math.min(1, limb.senesceT + dt / SENESCE_MS);
if (limb.senesceT >= 1) rebirth(limb, rand, wordCount);
return;
}
}
}
function buildRun(words: string[], startIdx: number, maxChars: number): string {
if (words.length === 0 || maxChars < 1) return "";
let out = "";
let i = startIdx;
for (let guard = 0; guard < 40; guard++) {
const w = words[i % words.length]!;
const candidate = out.length === 0 ? w : `${out} ${w}`;
if (out.length > 0 && candidate.length > maxChars) break;
out = candidate;
i++;
if (out.length >= maxChars) break;
}
return out;
}
const DEFAULT_TEXT =
"growth begins as a single word and forks into every branch it can still afford to reach light finds its way through language turning each fork into a fresh sentence rooted in the last";
export interface BackgroundTextBranchCanopyProps {
/** source text whose words run along each limb, windowed to fit and cycled per limb as the canopy grows and turns over. */
text?: string;
/** extra classes merged onto the rendered root element */
className?: string;
/** inline styles merged onto the root element */
style?: CSSProperties;
}
export function BackgroundTextBranchCanopy({ text = DEFAULT_TEXT, className = "", style }: BackgroundTextBranchCanopyProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const words = text.trim().split(/\s+/).filter(Boolean);
const rand = mulberry32(0x9a5c0de1);
let disposed = false;
let raf = 0;
let last = 0;
let acc = 0;
let simTime = 0;
let visible = true;
let paused = false;
let dpr = 1;
let w = 0;
let h = 0;
let cellW = 8; // refined after document.fonts.ready via offscreen measureText
let fontSize = 12;
let fgCss = "";
let mutedCss = "";
let limbs: Limb[] = [];
const readTokens = () => {
fgCss = getComputedStyle(canvas).color;
const root = getComputedStyle(document.documentElement);
mutedCss = root.getPropertyValue("--ns-muted").trim() || fgCss;
};
const measureCell = (fontFamily: string) => {
const off = document.createElement("canvas");
const octx = off.getContext("2d");
if (!octx) return;
octx.font = `${fontSize}px ${fontFamily}`;
cellW = Math.max(3, octx.measureText("MMMMMMMMMM").width / 10);
};
const rebuild = () => {
const minDim = Math.min(w, h);
fontSize = Math.min(15, Math.max(9, minDim * 0.022));
const fontFamily = getComputedStyle(canvas).fontFamily;
measureCell(fontFamily);
ctx.font = `${fontSize}px ${fontFamily}`;
limbs = buildTopology(w, h, cellW, rand, words.length);
};
const resize = () => {
const rect = canvas.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) return;
const isCard = !!canvas.closest("[data-autoplay-root]");
dpr = isCard ? Math.min(0.6, window.devicePixelRatio || 1) : Math.min(window.devicePixelRatio || 1, 2);
w = rect.width;
h = rect.height;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
rebuild();
};
const stepAll = (dt: number) => {
simTime += dt;
for (const limb of limbs) {
const parentReady = limb.parent === -1 || limbs[limb.parent]!.growProgress >= CHILD_START_FRAC;
tick(limb, dt, rand, words.length, parentReady);
}
};
// trunk foot biased into the right two-thirds of the frame — see
// CONTENT PROTECTION in the file header. Growth runs generally straight
// up (symmetric spread) from there, kept clear of a bottom-left-docked
// content block by the horizontal bias alone.
const originX = () => w * 0.74;
const originY = () => h * 0.94;
const draw = () => {
ctx.clearRect(0, 0, w, h);
const ox = originX();
const oy = originY();
const maxDepth = limbs.reduce((m, l) => Math.max(m, l.depth), 1);
for (let i = 0; i < limbs.length; i++) {
const limb = limbs[i]!;
const parent = limb.parent === -1 ? null : limbs[limb.parent]!;
limb.footX = parent ? parent.tipX : ox;
limb.footY = parent ? parent.tipY : oy;
const sway = limb.swayAmp * Math.sin(simTime * limb.swayFreq + limb.swayPhase);
const parentAngle = parent ? parent.absAngle : 0;
limb.absAngle = parentAngle + limb.relAngle + sway;
const shrinkFrac = limb.state === "senescing" ? 1 - limb.senesceT * (1 - SHRINK_FLOOR) : 1;
const lenCur = limb.lenFull * limb.growProgress * shrinkFrac;
limb.tipX = limb.footX + Math.cos(limb.absAngle) * lenCur;
limb.tipY = limb.footY + Math.sin(limb.absAngle) * lenCur;
if (lenCur < cellW * 1.5) continue; // too short to hold even one legible character yet — a young or nearly-shed stub, skip the draw this frame
const maxChars = Math.max(1, Math.floor(lenCur / cellW));
const run = buildRun(words, limb.wordStart, maxChars);
if (!run) continue;
const depthFrac = maxDepth > 0 ? limb.depth / maxDepth : 0;
let alpha = 1 - depthFrac * 0.68;
if (limb.state === "growing") alpha *= limb.growProgress;
if (limb.state === "senescing") alpha *= 1 - limb.senesceT * 0.75;
if (alpha <= 0.02) continue;
const cosA = Math.cos(limb.absAngle);
const flip = cosA < 0;
const drawAngle = flip ? limb.absAngle + Math.PI : limb.absAngle;
ctx.save();
ctx.translate(limb.footX, limb.footY);
ctx.rotate(drawAngle);
ctx.textAlign = flip ? "right" : "left";
ctx.textBaseline = "middle";
ctx.globalAlpha = alpha;
ctx.fillStyle = depthFrac < 0.4 ? fgCss : mutedCss;
ctx.fillText(run, 0, 0);
ctx.restore();
}
ctx.globalAlpha = 1;
};
const fastForward = (ms: number) => {
let remaining = ms;
let guard = 0;
while (remaining > 0 && guard < MAX_REDUCED_TICKS) {
stepAll(STEP_MS);
remaining -= STEP_MS;
guard++;
}
};
const loop = (now: number) => {
raf = 0;
if (disposed || paused) return;
if (last === 0) last = now;
let dt = now - last;
last = now;
if (dt > 250) dt = 250;
acc += dt;
while (acc >= STEP_MS) {
acc -= STEP_MS;
stepAll(STEP_MS);
}
draw();
raf = requestAnimationFrame(loop);
};
const startLive = () => {
last = 0;
paused = false;
if (!raf) raf = requestAnimationFrame(loop);
};
const stopLive = () => {
cancelAnimationFrame(raf);
raf = 0;
paused = true;
};
const reducedQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const applyMotionPref = () => {
stopLive();
if (reducedQuery.matches) {
fastForward(REDUCED_FREEZE_MS);
draw();
} else {
fastForward(PREWARM_MS);
draw();
if (visible) startLive();
}
};
reducedQuery.addEventListener("change", applyMotionPref);
readTokens();
const mo = new MutationObserver(() => {
readTokens();
if (!raf) draw(); // repaint immediately on a theme flip even while paused/reduced-motion
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const ro = new ResizeObserver(() => {
resize();
if (reducedQuery.matches) {
fastForward(REDUCED_FREEZE_MS);
draw();
} else if (!raf && visible) {
draw();
}
});
ro.observe(canvas);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (!reducedQuery.matches) {
if (visible) startLive();
else stopLive();
}
});
io.observe(canvas);
const onVisibility = () => {
if (document.hidden) stopLive();
else if (visible && !reducedQuery.matches) startLive();
};
document.addEventListener("visibilitychange", onVisibility);
document.fonts.ready.then(() => {
if (disposed) return;
rebuild();
if (reducedQuery.matches) {
fastForward(REDUCED_FREEZE_MS);
draw();
}
});
resize();
applyMotionPref();
return () => {
disposed = true;
stopLive();
ro.disconnect();
io.disconnect();
mo.disconnect();
reducedQuery.removeEventListener("change", applyMotionPref);
document.removeEventListener("visibilitychange", onVisibility);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
return (
<div
aria-hidden="true"
className={`relative h-full w-full overflow-hidden bg-background ${className}`}
style={style}
>
<canvas ref={canvasRef} className="absolute inset-0 h-full w-full text-foreground" style={{ fontFamily: "var(--font-mono)" }} />
{/* CONTENT PROTECTION: a --background-tinted scrim, strongest along the
bottom edge (the reading zone the demo docks its headline/CTA in)
and easing to fully transparent by mid-frame, so real overlaid type
sits on a real reading surface instead of fighting a canopy of
words. Trunk origin bias alone (see originX/originY above) is not
enough on its own — the crown still reaches the lower third on
tall containers — so both are required together. */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"linear-gradient(to bottom, transparent 0%, transparent 52%, color-mix(in srgb, var(--background) 55%, transparent) 78%, color-mix(in srgb, var(--background) 92%, transparent) 100%)",
}}
/>
</div>
);
}
BackgroundTextBranchCanopy.displayName = "BackgroundTextBranchCanopy";
Build spec
Build <BackgroundTextBranchCanopy text? className? style?> as a full-bleed ambient background: a Canvas 2D L-system tree whose every limb is drawn with exactly one ctx.fillText() call after translate(foot)+rotate(limbAngle) — a run of words from `text`, windowed to that limb's own length, pivoting on its own foot as it angles away from its parent. THE PRIMITIVE IS TEXT, NOT A TEXTURED STROKE: there is no path/stroke anywhere in the render, and no per-character grid — this is what separates it from the registry's ASCII family (glyph-per-cell luminance stand-ins on a fixed grid, meaningless characters) and from the registry's growth family, tendril-cast/auxin-canal/tree-root-trace/cambium-lay (all grow strokes that are colored or textured afterward). READABILITY DRIVES DEPTH, NOT THE REVERSE: trunk length is 0.28*min(container w,h), each generation is 0.72x the last, and a generation only recurses once its length can still hold ~4 monospace characters at the container-derived type size (9-15px, clamped, and NEVER shrunk by depth — recession is expressed as fewer words and lower alpha only). PIVOT ON THE FOOT: translate is always the limb's own foot (its parent's tip); when a limb's absolute angle points generally leftward (cos<0) it draws from angle+PI with textAlign 'right' instead of 'left' so the run stays upright and readable rather than mirrored, without moving the pivot. ALIVE FOREVER, TWO STRUCTURAL CHANNELS: (1) only the outer canopy (depth >= 2) ages — each churning limb has its own randomized 7-15s lifespan, senesces down to a stub over 1.5s, then regrows from the same foot with a new word offset, so the crown continually sheds and replaces its outer growth while the trunk/first branch level hold still; (2) every limb's angle carries its own sway term ON TOP of its parent's already-swayed angle (composed via a parent-before-child tree walk each frame), so the whole crown breathes rather than jittering per-limb. Both run unconditionally at rest, no pointer required. TOKENS: --foreground for the nearer ~40% of limbs by generation order, --ns-muted for the rest, with alpha also stepping down by depth on top of that — the near/far read holds by value alone in both themes, no luminance comparison needed. --ns-accent never appears; this is ambient, not interaction chrome. Canvas fillStyle can't take a raw var() string, so both tokens are read via getComputedStyle at mount and re-read on a MutationObserver watching documentElement's class. CONTENT PROTECTION: the trunk is origin-biased into the upper-left two-thirds of the frame, and a --background-tinted linear-gradient scrim (transparent through the top half, strongest at the bottom edge) sits above the canvas so a bottom-docked headline/CTA has a real reading surface — trunk bias alone still lets the crown reach the lower third, so both are required together. prefers-reduced-motion fast-forwards the simulation ~16s past first full maturation into steady turnover before freezing, so the frozen frame shows varied limb ages/depths, not a single freshly-grown tree; the live path is also fast-forwarded ~9s before first paint so t=0 already shows a full crown rather than a bare stub. DPR capped at 2 (1.5 blurs sub-15px monospace past legibility), 0.6 inside an autoplay card, render loop paused on IntersectionObserver-offscreen and visibilitychange. Props: text (source words, default a short passage about growth/branching), className, style.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text? | string | DEFAULT_TEXT | source text whose words run along each limb, windowed to fit and cycled per limb as the canopy grows and turns over. |
| className? | string | — | extra classes merged onto the rendered root element |
| style? | CSSProperties | — | inline styles merged onto the root element |