Dragon Curve
Start with a single line. Copy it, and swing the copy a quarter turn round the line’s end, like folding a strip of paper in half and opening it out at right angles. Do it again and again, and a dragon folds out of the line, its coils touching but never crossing.
How to use it
- Paste the snippet just before
</head>. - Add
data-dragon-curveto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-depth | Number of folds, 4 to 15. Default fits the box. |
data-fold | Time per fold. Default 900 ms. |
data-colors | Three colours along the curve. Default #f472b6,#a78bfa,#38bdf8. |
data-loop | Hold the finished dragon, fade it and fold again. |
The view glides to keep the whole curve in frame as it doubles. It’s drawn on a transparent canvas and paused when off screen. With reduced motion it shows the finished dragon.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Dragon Curve: start with a single line. Copy it, and swing the copy a
// quarter turn round the line's end, like folding a strip of paper in
// half and opening it out at right angles. Do it again and again, and a
// dragon folds out of the line, its coils never crossing. The view keeps
// the whole curve in frame as it grows. Drawn on a transparent canvas and
// paused when off screen.
// Usage: <div data-dragon-curve style="height:360px"></div>
// Optional: data-depth (number of folds, default fits the box, up to 15),
// data-fold (time per fold in ms, default 900), data-colors (three
// colours along the curve, default "#f472b6,#a78bfa,#38bdf8"), data-loop
// (hold the finished dragon, fade it and fold again).
var DEFAULTS = { fold: 900, colors: '#f472b6,#a78bfa,#38bdf8', pause: 260, hold: 3200 };
var BINS = 60;
var css =
'[data-dragon-curve]{position:relative;isolation:isolate;overflow:hidden}' +
'.drc-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.trim().replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'drc-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var foldMs = +d.fold || DEFAULTS.fold;
var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
while (cs.length < 3) cs.push(cs[cs.length - 1]);
var loop = d.loop !== undefined && d.loop !== 'false';
var colors = [];
for (var b = 0; b < BINS; b++) {
var k = b / (BINS - 1), c0 = k < 0.5 ? cs[0] : cs[1], c1 = k < 0.5 ? cs[1] : cs[2], f = k < 0.5 ? k * 2 : k * 2 - 1;
colors.push('rgb(' + Math.round(c0[0] + (c1[0] - c0[0]) * f) + ',' + Math.round(c0[1] + (c1[1] - c0[1]) * f) + ',' + Math.round(c0[2] + (c1[2] - c0[2]) * f) + ')');
}
var w = 0, h = 0, dpr = 1, depth = 12;
// pts: the curve so far, as x, y pairs; n points. Room for the deepest fold.
var pts = new Float64Array(2 * (Math.pow(2, 15) + 1)), n = 2;
var view = null, folds = 0, age = 0, phase = 'fold', fading = false;
function reset() {
pts[0] = 0; pts[1] = 0; pts[2] = 1; pts[3] = 0;
n = 2;
folds = 0;
age = 0;
phase = 'pause';
view = null;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return false;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Stop folding once a step would be shorter than about 2.5 px.
depth = +d.depth || Math.floor(2 * Math.log(Math.min(w, h) * 0.6 / 2.5) / Math.LN2);
depth = Math.max(4, Math.min(15, depth));
return true;
}
// Commit a finished fold: append the copy, swung a quarter turn.
function commit() {
var px = pts[(n - 1) * 2], py = pts[(n - 1) * 2 + 1];
for (var i = n - 2; i >= 0; i--) {
var dx = pts[i * 2] - px, dy = pts[i * 2 + 1] - py;
pts[n * 2] = px - dy;
pts[n * 2 + 1] = py + dx;
n++;
}
folds++;
}
function draw(a) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var px = pts[(n - 1) * 2], py = pts[(n - 1) * 2 + 1], c = Math.cos(a), s = Math.sin(a);
var total = a ? n * 2 - 1 : n;
// Point i of the whole curve, including the swinging copy.
function at(i, out) {
if (i < n) { out[0] = pts[i * 2]; out[1] = pts[i * 2 + 1]; return; }
var j = 2 * n - 2 - i, dx = pts[j * 2] - px, dy = pts[j * 2 + 1] - py;
out[0] = px + dx * c - dy * s;
out[1] = py + dx * s + dy * c;
}
var p = [0, 0], minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (var i = 0; i < total; i++) {
at(i, p);
if (p[0] < minX) minX = p[0];
if (p[0] > maxX) maxX = p[0];
if (p[1] < minY) minY = p[1];
if (p[1] > maxY) maxY = p[1];
}
// Fit the box, easing towards the new fit so it glides.
var pad = Math.max(14, Math.min(w, h) * 0.08);
var target = { s: Math.min((w - pad * 2) / Math.max(1e-6, maxX - minX), (h - pad * 2) / Math.max(1e-6, maxY - minY)), x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
if (!view) view = target;
else {
view.s += (target.s - view.s) * 0.12;
view.x += (target.x - view.x) * 0.12;
view.y += (target.y - view.y) * 0.12;
}
var sc = view.s, ox = w / 2 - view.x * sc, oy = h / 2 - view.y * sc;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1, Math.min(4, sc * 0.4));
var per = Math.max(1, Math.ceil((total - 1) / BINS));
for (var bin = 0; bin * per < total - 1; bin++) {
var from = bin * per, to = Math.min(total - 1, from + per);
ctx.beginPath();
at(from, p);
ctx.moveTo(ox + p[0] * sc, oy + p[1] * sc);
for (i = from + 1; i <= to; i++) {
at(i, p);
ctx.lineTo(ox + p[0] * sc, oy + p[1] * sc);
}
ctx.strokeStyle = colors[Math.min(BINS - 1, Math.floor(from / (total - 1) * BINS))];
ctx.stroke();
}
}
function angle() {
if (phase !== 'fold') return 0;
var k = Math.min(1, age / foldMs);
return (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2) * Math.PI / 2;
}
function tick(dt) {
age += dt;
if (phase === 'pause' && age > DEFAULTS.pause) { phase = 'fold'; age = 0; }
else if (phase === 'fold' && age >= foldMs) {
commit();
age = 0;
phase = folds >= depth ? 'hold' : 'pause';
} else if (phase === 'hold' && loop && age > DEFAULTS.hold && !fading) {
fading = true;
anime.animate(canvas, {
opacity: 0, duration: 600, ease: 'inOut(2)',
onComplete: function () { reset(); fading = false; anime.utils.set(canvas, { opacity: 1 }); }
});
}
}
resize();
reset();
if (reduced()) {
while (folds < depth) commit();
draw(0);
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) { reset(); while (folds < depth) commit(); draw(0); } }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) reset(); }).observe(el);
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;
tick(dt);
draw(angle());
}
});
// 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-dragon-curve]').forEach(function (el) {
if (el._drc) return;
el._drc = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-dragon-curve data-loop style="height: 360px; background: #0d0820"></div>