Fractal Tree
A tree grows from a single trunk. Every branch splits into two smaller ones, again and again, until blossoms open at the tips. Then it sways in gusts of wind: the twigs bend most and the trunk hardly moves. Tap to grow another.
Tap to grow another
How to use it
- Paste the snippet just before
</head>. - Add
data-fractal-treeto a box with a height.
Options
Add any of these attributes to the same element.
data-depth | How many times the branches split, 3 to 13. Default 10. |
data-angle | Spread between each pair of branches, in degrees. Default 30. |
data-color | Trunk colour. Default #4a3428. |
data-leaf | Twig colour, which the branches fade towards. Default #a8577e. |
data-blossom | Blossom colour, or none. Default #f9a8d4. |
data-wind | Strength of the wind, 0 for still. Default 1. |
data-duration | Growing time. Default 3500 ms. |
data-loop | Hold the grown tree for a few seconds, then grow a new one. |
Every branch gets a slightly random length and turn, so no two trees are alike. It’s drawn on a transparent canvas and paused when off screen. With reduced motion the tree appears fully grown and still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Fractal Tree: a tree grows from a single trunk. Every branch splits in
// two smaller branches, again and again, until blossoms open at the tips.
// The finished tree sways in gusts of wind: the twigs bend most, the
// trunk hardly at all. Every tree is a little different. Tap to grow
// another. Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-fractal-tree style="height:360px"></div>
// Optional: data-depth (how many times the branches split, default 10),
// data-angle (spread between two branches in degrees, default 30),
// data-color (trunk, default #4a3428), data-leaf (twigs, default #a8577e),
// data-blossom (default #f9a8d4, or "none"), data-wind (strength, 0 for
// still, default 1), data-duration (growing time in ms, default 3500),
// data-loop (hold the grown tree, then grow a new one).
var DEFAULTS = { depth: 10, angle: 30, color: '#4a3428', leaf: '#a8577e', blossom: '#f9a8d4', wind: 1, duration: 3500, hold: 6000 };
var css =
'[data-fractal-tree]{position:relative;isolation:isolate;overflow:hidden}' +
'.frt-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
function mix(a, b, k) {
return 'rgb(' + Math.round(a[0] + (b[0] - a[0]) * k) + ',' + Math.round(a[1] + (b[1] - a[1]) * k) + ',' + Math.round(a[2] + (b[2] - a[2]) * k) + ')';
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'frt-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var depth = Math.max(3, Math.min(13, +d.depth || DEFAULTS.depth));
var spread = (d.angle ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
var wind = d.wind !== undefined ? +d.wind : DEFAULTS.wind;
var duration = +d.duration || DEFAULTS.duration;
var loop = d.loop !== undefined && d.loop !== 'false';
var trunk = rgb(d.color || DEFAULTS.color), twig = rgb(d.leaf || DEFAULTS.leaf);
var blossom = (d.blossom || DEFAULTS.blossom) === 'none' ? null : d.blossom || DEFAULTS.blossom;
var colors = [];
for (var i = 0; i <= depth; i++) colors.push(mix(trunk, twig, Math.pow(i / depth, 1.3)));
var w = 0, h = 0, dpr = 1, unit = 1;
// Each branch, numbered like a heap (children of i are 2i+1 and 2i+2),
// gets its own random length, turn and bloom.
var count = Math.pow(2, depth + 1) - 1;
var lens = new Float32Array(count), turns = new Float32Array(count), blooms = new Float32Array(count);
var t = 0, age = 0, waiting = false;
var paths = [], tips = [];
function seed() {
var lean = (Math.random() - 0.5) * 0.12;
for (var i = 0; i < count; i++) {
lens[i] = 0.68 + Math.random() * 0.12;
turns[i] = (Math.random() - 0.5) * 0.35;
blooms[i] = Math.random();
}
lens[0] = 1;
turns[0] = lean;
age = 0;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Trunk length, so the crown fits both ways.
unit = Math.min(h * 0.26, w * 0.2);
}
// Walk the tree, collecting each depth's branches into one path.
function walk(i, x, y, ang, len, lvl, grown, gust) {
var f = grown - lvl;
if (f <= 0) return;
if (f > 1) f = 1;
// Thin branches bend more in the wind.
var a = ang + turns[i] + gust * (lvl + 1) * (lvl + 1) * 0.0022 + Math.sin(t / 900 + i * 0.7) * 0.012 * lvl * wind;
var ex = x + Math.cos(a) * len * f, ey = y + Math.sin(a) * len * f;
paths[lvl].push(x, y, ex, ey);
if (lvl === depth) {
if (f >= 1) tips.push(ex, ey, blooms[i], Math.min(1, grown - depth));
return;
}
if (f < 1) return;
walk(2 * i + 1, ex, ey, a - spread / 2, len * lens[2 * i + 1], lvl + 1, grown, gust);
walk(2 * i + 2, ex, ey, a + spread / 2, len * lens[2 * i + 2], lvl + 1, grown, gust);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var p = Math.min(1, age / duration);
var grown = (1 - Math.pow(1 - p, 1.6)) * (depth + 1.6);
// Gusts: two slow waves and a quicker flutter on top.
var gust = wind * (Math.sin(t / 2300) * 0.8 + Math.sin(t / 1100 + 1) * 0.35 + Math.sin(t / 3700) * 0.5);
paths = [];
for (var i = 0; i <= depth; i++) paths.push([]);
tips = [];
walk(0, w / 2, h - Math.max(6, h * 0.03), -Math.PI / 2, unit, 0, grown, gust);
ctx.lineCap = 'round';
var base = Math.max(3, unit / 9);
for (i = 0; i <= depth; i++) {
var seg = paths[i];
if (!seg.length) continue;
ctx.beginPath();
for (var j = 0; j < seg.length; j += 4) {
ctx.moveTo(seg[j], seg[j + 1]);
ctx.lineTo(seg[j + 2], seg[j + 3]);
}
ctx.strokeStyle = colors[i];
ctx.lineWidth = Math.max(0.6, base * Math.pow(0.68, i));
ctx.stroke();
}
if (!blossom) return;
// Blossoms open on about half the tips.
ctx.fillStyle = blossom;
var r = Math.max(1.4, Math.min(3.4, unit / 30));
for (i = 0; i < tips.length; i += 4) {
if (tips[i + 2] > 0.55) continue;
var open = Math.max(0, Math.min(1, tips[i + 3] * 2.5 - tips[i + 2]));
if (!open) continue;
ctx.globalAlpha = 0.55 + tips[i + 2] * 0.6;
ctx.beginPath();
ctx.arc(tips[i], tips[i + 1], r * open * (0.7 + tips[i + 2]), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function next() {
if (waiting) return;
waiting = true;
anime.animate(canvas, {
opacity: 0, duration: 500, ease: 'inOut(2)',
onComplete: function () {
seed();
waiting = false;
anime.utils.set(canvas, { opacity: 1 });
}
});
}
resize();
seed();
if (reduced()) {
// The grown tree, standing still.
age = duration * 3;
wind = 0;
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
el.addEventListener('pointerdown', next);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
t += dt;
age += dt;
if (loop && age > duration * 1.3 + DEFAULTS.hold) next();
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-fractal-tree]').forEach(function (el) {
if (el._frt) return;
el._frt = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-fractal-tree style="height: 360px"></div>
<!-- a new tree every few seconds: -->
<div data-fractal-tree data-loop style="height: 360px"></div>