Sunflower Spiral
Seeds are placed one after another, each turned by the golden angle (about 137.5°) from the last and a little further out. They pack together without gaps, and the criss-crossing spirals of a sunflower head appear as the head slowly turns.
How to use it
- Paste the snippet just before
</head>. - Add
data-sunflower-spiralto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-count | Number of seeds. Default fits the box, 150 to 1600. |
data-angle | Turn between seeds, in degrees. Default 137.508. Try 137.3 or 90 to see why the golden angle is special. |
data-colors | Centre, middle and edge colours. Default #9a3412,#f97316,#fde047. |
data-duration | Time to place every seed. Default 6000 ms. |
data-spin | Turns per minute. Default 1.5. |
data-loop | Hold the full head, gather the seeds back in and start again. |
Seed n sits at a distance of √n from the centre, so every seed gets the same room. The golden angle is the “most irrational” turn, so no two seeds ever line up. It’s paused when off screen, and with reduced motion it shows the full head.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Sunflower Spiral: seeds are placed one after another, each turned by
// the golden angle (about 137.5 degrees) from the last and a little
// further out. They pack together with no gaps, and the criss-crossing
// spirals of a sunflower head appear. The head turns slowly. Drawn on a
// transparent canvas and paused when off screen.
// Usage: <div data-sunflower-spiral style="height:360px"></div>
// Optional: data-count (number of seeds, default fits the box),
// data-angle (turn between seeds in degrees, default 137.508; try 137.3
// or 90 to see why the golden angle is special), data-colors (centre,
// middle and edge colours, default "#9a3412,#f97316,#fde047"),
// data-duration (time to place every seed in ms, default 6000), data-spin
// (turns per minute, default 1.5), data-loop (hold the full head, gather
// the seeds back in and start again).
var DEFAULTS = { angle: 137.508, colors: '#9a3412,#f97316,#fde047', duration: 6000, spin: 1.5, hold: 3500 };
var css =
'[data-sunflower-spiral]{position:relative;isolation:isolate;overflow:hidden}' +
'.sfl-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 = 'sfl-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var turn = (d.angle ? +d.angle : DEFAULTS.angle) * Math.PI / 180;
var cs = (d.colors || DEFAULTS.colors).split(',').map(rgb);
while (cs.length < 3) cs.push(cs[cs.length - 1]);
var duration = +d.duration || DEFAULTS.duration;
var spin = (d.spin !== undefined ? +d.spin : DEFAULTS.spin) * Math.PI * 2 / 60000;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1, n = 0, R = 0, step = 1, palette = [];
var t = 0, age = 0, phase = 'grow', gather = 0;
// Seed colours from centre to edge, in a few dozen shades.
for (var i = 0; i < 48; i++) {
var k = i / 47, a = k < 0.5 ? cs[0] : cs[1], b = k < 0.5 ? cs[1] : cs[2], f = k < 0.5 ? k * 2 : k * 2 - 1;
palette.push('rgb(' + Math.round(a[0] + (b[0] - a[0]) * f) + ',' + Math.round(a[1] + (b[1] - a[1]) * f) + ',' + Math.round(a[2] + (b[2] - a[2]) * f) + ')');
}
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);
R = Math.min(w, h) / 2 * 0.88;
n = +d.count || Math.max(150, Math.min(1600, Math.round(Math.pow(R / 4.6, 2))));
// Seed n sits on the rim: r = step * sqrt(n).
step = R / Math.sqrt(n);
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var cx = w / 2, cy = h / 2, rot = t * spin;
// How many seeds are out, with the newest still swelling in.
var shown = phase === 'grow' ? n * Math.min(1, age / duration) : n;
var pull = phase === 'gather' ? gather : 0;
for (var i = 0; i < n; i++) {
var grow = Math.min(1, (shown - i) / (n * 0.04 + 6));
if (grow <= 0) break;
var r = step * Math.sqrt(i + 0.5);
var k = r / R;
// While gathering, the outer seeds fall in first.
var g = Math.max(0, Math.min(1, pull * 1.6 - (1 - k) * 0.6));
var e = 1 - Math.pow(1 - grow, 3);
var rr = r * (1 - g) * (0.6 + e * 0.4);
var a = i * turn + rot - g * 1.2;
var size = step * (0.42 + k * 0.12) * e * (1 - g);
if (size < 0.2) continue;
ctx.fillStyle = palette[Math.min(47, Math.floor(k * 47))];
ctx.beginPath();
ctx.arc(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, size, 0, Math.PI * 2);
ctx.fill();
}
}
function tick(dt) {
t += dt;
age += dt;
if (phase === 'grow' && age > duration) { phase = 'hold'; age = 0; }
else if (phase === 'hold' && loop && age > DEFAULTS.hold) { phase = 'gather'; age = 0; }
else if (phase === 'gather') {
gather = Math.min(1, age / 1400);
gather = gather * gather;
if (age > 1400) { phase = 'grow'; age = 0; gather = 0; }
}
}
resize();
if (reduced()) {
phase = 'hold';
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).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();
}
});
// 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-sunflower-spiral]').forEach(function (el) {
if (el._sfl) return;
el._sfl = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-sunflower-spiral style="height: 360px; background: #140a26"></div>