How I Built OrganicGrowth
Bézier stems, stroke-dashoffset and a seeded random generator: the recipe behind the vines that grow around my cards.
TL;DR
OrganicGrowth is a React component that grows SVG vines, leaves and flowers around an element on hover. Each stem is a cubic Bézier path revealed with stroke-dashoffset, leaves pop in timed to when the stroke reaches them, and a seeded random generator keeps every plant organic but repeatable. It uses no canvas and no animation library, only SVG and CSS transitions.
Hover a card in the Lab, and a little plant grows out of the edges: stems curl outward, leaves pop open along them, and a few flowers bloom at the tips. Move away and it all folds back. It's called OrganicGrowth, and it's one SVG plus CSS transitions. No canvas, no physics and no animation library. Here's how it works.
The ingredients
- Stems are SVG paths, “drawn” with the
stroke-dashoffsettrick. - Each stem's shape is one cubic Bézier curve, optionally finished with a hand-rolled curl.
- Leaves and flowers are tiny SVG groups that scale up from 0, timed to the moment the stem reaches them.
- A seeded random generator makes the plant look hand-drawn, but identical on every render.
- All the geometry is computed up front from the element's size. Nothing is measured from the DOM while it animates.
Drawing a line that grows
The core trick is old but lovely. Give a path a dash pattern as long as the path itself, then offset that dash by the same length: the visible dash slides completely off the path, so nothing shows. Transition the offset back to 0 and the line appears to draw itself from start to end.
const stemStyle = (s: StemShape): CSSProperties => ({
strokeDasharray: `${s.length} ${s.length + 4}`,
strokeDashoffset: grown || reduced ? 0 : s.length + 2,
transition: reduced
? "none"
: grown
? `stroke-dashoffset ${s.duration}s ${EASE_GROW} ${s.delay}s`
: `stroke-dashoffset ${0.5 / speed}s ${EASE_RETRACT} ${s.retractDelay}s`,
});Notice the gap is a few pixels longer than the dash, and the hidden offset sits just past it. With a plain length length pattern the far end of the path lands exactly on the next dash, and because stems use round line caps, that leaves a stray dot at every tip while the plant is retracted.
Growing and retracting use different easings on purpose. Growth eases out (cubic-bezier(.25,.6,.35,1)), so it starts quickly and settles. Retracting eases in and is faster, so leaving the hover feels snappy rather than mournful.
Shaping a stem with a Bézier curve
Each sprout is described by an edge, a position along it (t, from 0 to 1) and which way it bends. From that I get three things: the start point S on the edge, a heading pointing straight out of the element (the edge's normal), and a bend direction along the edge. The end point and both control points fall out of a few multipliers:
const E = add(add(S, mul(heading, len * 0.55)), mul(bend, len * 0.75));
const c1 = add(S, { x: heading.x * len * 0.45 + jitter(), y: heading.y * len * 0.45 + jitter() });
const c2 = add(E, {
x: -bend.x * len * 0.35 + heading.x * len * 0.05 + jitter(),
y: -bend.y * len * 0.35 + heading.y * len * 0.05 + jitter(),
});So the stem leaves the edge heading straight out (that's c1), then leans over sideways toward E, like a plant arching toward light. jitter() nudges the control points by up to ±10% of the length, which is what stops ten stems from looking stamped out of one mould. Each path also starts a few pixels inside the element (the tuck option), so stems look like they grow from under the card instead of being glued to its border.
Curling the tip
A plain Bézier ends abruptly. For tips that should curl, I keep walking from E in small steps, turning a little more each step while the steps get shorter. That traces a tightening spiral, like a tendril:
let th = Math.atan2(E.y - c2.y, E.x - c2.x); // keep the curve's final direction
let p = { ...E };
const dir = rand() < 0.5 ? 1 : -1; // curl clockwise or anticlockwise
for (let k = 0; k < 16; k++) {
th += dir * (0.28 + k * 0.035); // turn harder each step…
const step = 7 * (1 - k / 16) + 1.4; // …while the steps shrink
p = add(p, { x: Math.cos(th) * step, y: Math.sin(th) * step });
d += ` L${r1(p.x)} ${r1(p.y)}`;
}Because the curl is just more points on the same path, the dash trick draws it for free: the line grows out along the stem and rolls up at the end.
Leaves that appear on cue
Leaves should pop open the moment the stroke passes them, not all at once. To know where along the curve a leaf sits and when the stroke gets there, I sample the Bézier into 41 points, measure the running length, and look up any distance along it:
const count = Math.max(2, Math.round(stemLen / 24)); // roughly one leaf per 24px
for (let i = 0; i < count; i++) {
const at = from + (stemLen - from) * (0.08 + i * (0.86 / count) + rand() * 0.04);
const { p, angle } = pointAt(stemPoly, at); // position + direction on the curve
garden.leaves.push({
x: p.x, y: p.y,
angle: angle + leafSide * (45 + rand() * 25), // splay off the stem
scale: (1.15 - (at / stemLen) * 0.5) * (0.85 + rand() * 0.3), // smaller toward the tip
delay: delay + duration * (at / total) * 0.85, // arrive just as the stroke does
});
leafSide *= -1; // alternate sides
}Each leaf is a single path (two quadratic curves meeting in a point) plus a faint midrib. It animates in with a springy cubic-bezier(.34,1.56,.64,1) scale from 0, and the overshoot is what makes it read as “popping open”. Flowers work the same way: five rotated ellipses and a pollen dot, timed to the tip, or to any point along the stalk with the blooms option.
The same plant every time
Random looks organic, but Math.random() would give a different plant on every render (and a different one on the server than in the browser). Instead the garden uses a tiny seeded generator. Same seed, same plant:
/** Park–Miller PRNG so a given seed always produces the same plant. */
function createRandom(seed: number) {
let s = Math.max(1, Math.floor(Math.abs(seed)) % 2147483647);
return () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}seed
20261
Using it
All of that hides behind one wrapper. You describe where stalks sprout; the component measures the element, builds the garden once, and toggles it on hover or focus:
const SPROUTS: Sprout[] = [
{ edge: "top", t: 0.08, side: -1, length: 110, end: "curl", branch: false, blooms: [0.55] },
{ edge: "top", t: 0.28, side: 1, length: 75, end: "bloom", branch: false },
{ edge: "left", t: 0.62, side: -1, length: 85, end: "bloom", branch: false },
{ edge: "bottom", t: 0.86, side: 1, length: 45, end: "curl", branch: false },
];
<OrganicGrowth sprouts={SPROUTS} padding={150}>
<YourCard />
</OrganicGrowth>end:"bloom"puts a flower on the tip,"curl"a tendril.branch: stalks over 110px grow a side branch by default;falseturns it off.blooms: extra flowers along the stalk, as positions from 0 (base) to 1 (tip).seed,speed,staggerandpalettetune the arrangement, pace and colours.
Keeping it cheap
The geometry is rebuilt only when the element's size or the options change (a ResizeObserver plus useMemo). After that, hovering just flips one boolean, and the browser animates stroke-dashoffset and transform, nothing that triggers layout. The SVG is aria-hidden and pointer-events: none, so it never gets in the way, and with reduced motion turned on the plant simply fades in instead of growing.
Wrapping up
Three small ideas do most of the work: a dash offset that draws lines, a Bézier recipe that bends them like stems, and timing every leaf to the moment the stroke reaches it. Add a seeded random generator for character and it stops feeling like an animation and starts feeling like something growing. If you build something with it, I'd love to see it.
FAQ
- How do you animate an SVG line drawing itself?
- Set stroke-dasharray to the path's length and stroke-dashoffset to the same length so the dash is pushed off the path, then transition stroke-dashoffset to 0. OrganicGrowth makes the gap a few pixels longer than the dash (and offsets by length + 2) so round line caps don't leave a dot at the tip.
- How is each vine shaped?
- Each stem is one cubic Bézier curve. It starts on the element's edge, heads straight out along the edge normal, then bends sideways: the end point is S + heading·len·0.55 + bend·len·0.75, with small random jitter on the control points. Curled tips continue the path in a tightening spiral.
- How do the leaves appear at the right moment?
- The curve is sampled into 41 points so any distance along it can be looked up. Each leaf is placed at a distance along the stem and its animation delay is set to the fraction of the stroke drawn by then, so it pops open just as the line reaches it.
- Why use a seeded random generator?
- Math.random() would produce a different plant on every render and differ between server and browser. A small Park–Miller generator seeded with a number gives organic-looking variation that is identical every time for the same seed.
- Is it expensive to render?
- Geometry is computed once per element size, and hovering only toggles a boolean. The browser animates stroke-dashoffset and transform, which don't trigger layout. The main cost is the number of shapes: roughly one leaf per 24px of stalk and six shapes per flower.