Line draw — branching growth
Variant: Branching growth
How to draw any tree — a dependency graph, a git history, an org chart — without half of it growing out of thin air: an edge may not begin before the edge it hangs off has reached its junction. One pen holds 67 units a second across the entire figure, and each stroke is given two numbers, a height up the trunk and its own arc length, so nothing in the file is a delay someone tuned. Move a junction and the order rearranges itself. Branches are thinner than the trunk and a step lighter in the same family, because depth in a growth diagram has to be legible from a still frame as well as from the order things arrived in, and the ground is the one mark that never draws and never resets — it is the plot, not the plant.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Line draw — branching growth</title>
<style>
/* One registered number for the whole drawing. Every stroke reads it
through its own start offset and rate, which is what makes a branch
start when the trunk's front passes its junction rather than when a
delay someone typed happens to fire. */
@property --_p {
syntax: '<number>';
initial-value: 0;
inherits: true;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* One pen, one speed: 1.2972 user units per unit of progress, on every
stroke in the figure. A stroke starts at the progress its junction
sits at and runs at 100 divided by its own length in progress units,
so both numbers below are geometry — a height up the trunk and an arc
length. Nothing in this file decides how fast anything draws or in
what order; the drawing already did. */
--_trunk-at: 0;
--_trunk-k: 1.119; /* 116 units long, from the ground */
--_b1-at: 27.75;
--_b1-k: 2.754; /* 47.11 units, leaving the trunk 36 up */
--_b2-at: 47.8;
--_b2-k: 2.653; /* 48.91 units, 62 up */
--_b3-at: 67.84;
--_b3-k: 3.111; /* 41.72 units, 88 up */
/* A tip lights over the last 3 progress units of its own stroke, so it
lands on arrival rather than after it. */
--_tip-k: 0.3333;
--_tip-b1: 61.07;
--_tip-b2: 82.5;
--_tip-trunk: 86.42;
--_tip-b3: 97;
/* Clock — 6s: 3.78s of growth over 253.7 units of stroke, which is the
67 units a second the pen holds throughout, then a 1.2s hold on the
finished figure and a dark beat to re-arm in. Longer than anything
responsive because nothing here is a response — it is a diagram
assembling itself. */
--cycle: 6s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.tree {
width: min(250px, 82vw);
height: auto;
display: block;
animation: grow var(--t) infinite;
}
/* The ground is the substrate — it never draws and never resets, so the
loop reads as the same plot being planted again. */
.ground {
stroke: var(--oat);
stroke-width: 3;
stroke-linecap: round;
}
/* The 1.03 parks an unstarted stroke's round cap three units clear of its
own path start. At an offset of exactly 100 the cap still paints, and
on this figure that is three clay specks hanging in the air above the
trunk, on the junctions it has not reached yet. The gap is 200 rather
than 100 for the same reason at the far end. */
.limb {
fill: none;
stroke-linecap: round;
stroke-dasharray: 100 200;
stroke-dashoffset: calc(
(103 - clamp(0, calc((var(--_p) - var(--_at)) * var(--_k) * 1.03), 103)) * 1px
);
}
.trunk {
stroke: var(--clay-d);
stroke-width: 3.6;
--_at: var(--_trunk-at);
--_k: var(--_trunk-k);
}
/* Branches are thinner than the trunk and the same colour family one step
lighter, because depth in a growth diagram has to be legible from a
still frame as well as from the order things arrived in. */
.branch {
stroke: var(--clay);
stroke-width: 2.4;
}
.b1 {
--_at: var(--_b1-at);
--_k: var(--_b1-k);
}
.b2 {
--_at: var(--_b2-at);
--_k: var(--_b2-k);
}
.b3 {
--_at: var(--_b3-at);
--_k: var(--_b3-k);
}
.tip {
fill: var(--clay);
opacity: clamp(0, calc((var(--_p) - var(--_on)) * var(--_tip-k)), 1);
}
.tip-trunk {
fill: var(--clay-d);
--_on: var(--_tip-trunk);
}
.tip-b1 {
--_on: var(--_tip-b1);
}
.tip-b2 {
--_on: var(--_tip-b2);
}
.tip-b3 {
--_on: var(--_tip-b3);
}
.figure {
animation: figure-hold var(--t) infinite;
}
@keyframes grow {
0%,
5% {
--_p: 0;
animation-timing-function: linear;
}
68%,
94% {
--_p: 100;
}
100% {
--_p: 0;
}
}
/* The figure is dark by 94%, which is the only reason --_p can fall back
to zero over the last 6% without the drawing being seen un-growing. */
@keyframes figure-hold {
0%,
88% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* 1.8x, which puts the pen at 37 units a second. Growth order is the
whole content of the piece, so it survives; both clocks scale by the
same factor, which keeps the junctions where the geometry put them. */
.tree,
.figure {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<svg
class="tree"
viewBox="0 0 220 176"
role="img"
aria-label="A trunk drawing itself upward from the ground, with three branches each beginning to grow at the moment the trunk's leading edge passes their junction"
>
<line class="ground" x1="84" y1="162" x2="136" y2="162" />
<g class="figure">
<path class="limb trunk" pathLength="100" d="M 110 162 V 46" />
<path class="limb branch b1" pathLength="100" d="M 110 126 C 96 124, 84 114, 74 98" />
<path class="limb branch b2" pathLength="100" d="M 110 100 C 126 98, 138 88, 148 72" />
<path class="limb branch b3" pathLength="100" d="M 110 74 C 97 72, 86 66, 76 52" />
<circle class="tip tip-b1" cx="74" cy="98" r="4.5" />
<circle class="tip tip-b2" cx="148" cy="72" r="4.5" />
<circle class="tip tip-b3" cx="76" cy="52" r="4.5" />
<circle class="tip tip-trunk" cx="110" cy="46" r="5.5" />
</g>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.