Wireframe Polyhedra
The five Platonic solids, from the four-sided tetrahedron to the twenty-sided icosahedron, turn slowly in space as fine wireframes. Each spins on its own axis at its own pace, and the edges round the back fade so the shapes read as solid. Drag or flick to spin them.
How to use it
- Paste the snippet just before
</head>. - Add
data-wireframe-polyhedrato a box with a height. The solids share it out between them.
Options
Add any of these attributes to the same element.
data-shapes | Which solids, in order: any of tetra, cube, octa, dodeca and icosa, separated by commas. Default all five. |
data-color | Colour of the edges. Default #6d28d9. |
data-dots | Colour of the corners, or false for none. Default #db2777. |
data-speed | How fast they turn. Default 1. |
data-drag="false" | Don’t let people spin them by dragging. |
Drawn on a canvas, paused when off screen. With reduced motion the solids stand still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Wireframe Polyhedra: the five Platonic solids drawn as wireframes, each
// turning slowly in space on its own axis. Edges at the back fade, edges at
// the front are bold, so the shapes read as solid. Drag to spin them all.
// Usage: <div data-wireframe-polyhedra style="height: 300px"></div>
// Optional: data-shapes (comma list of tetra, cube, octa, dodeca, icosa;
// default all five), data-color (front edges, default #6d28d9), data-dots
// (colour of the corners, default #db2777; "false" for none), data-speed
// (default 1), data-drag="false".
var DEFAULTS = { shapes: 'tetra,cube,octa,dodeca,icosa', color: '#6d28d9', dots: '#db2777', speed: 1 };
var css =
'[data-wireframe-polyhedra]{position:relative;overflow:hidden;touch-action:pan-y}' +
'.wfp-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var PHI = (1 + Math.sqrt(5)) / 2;
// Corners of each solid; the edges are the pairs of corners closest together.
function corners(name) {
var v = [], i, s = [-1, 1];
if (name === 'tetra') return [[1, 1, 1], [1, -1, -1], [-1, 1, -1], [-1, -1, 1]];
if (name === 'octa') return [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]];
if (name === 'cube' || name === 'dodeca') {
for (i = 0; i < 8; i++) v.push([i & 1 ? 1 : -1, i & 2 ? 1 : -1, i & 4 ? 1 : -1]);
if (name === 'cube') return v;
}
var a = name === 'icosa' ? 1 : 1 / PHI, b = PHI;
s.forEach(function (p) {
s.forEach(function (q) {
v.push([0, p * a, q * b], [p * a, q * b, 0], [p * b, 0, q * a]);
});
});
return v;
}
function solid(name) {
var v = corners(name);
// Scale so every solid fits the same sphere.
var r = Math.sqrt(v[0][0] * v[0][0] + v[0][1] * v[0][1] + v[0][2] * v[0][2]);
v = v.map(function (p) { return [p[0] / r, p[1] / r, p[2] / r]; });
var min = Infinity, dist = [], e = [];
for (var i = 0; i < v.length; i++) for (var j = i + 1; j < v.length; j++) {
var dx = v[i][0] - v[j][0], dy = v[i][1] - v[j][1], dz = v[i][2] - v[j][2];
var d = dx * dx + dy * dy + dz * dz;
dist.push([i, j, d]);
if (d < min) min = d;
}
dist.forEach(function (p) { if (p[2] < min * 1.01) e.push([p[0], p[1]]); });
return { v: v, e: e };
}
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return (n >> 16 & 255) + ',' + (n >> 8 & 255) + ',' + (n & 255);
}
function build(el) {
var d = el.dataset;
var names = (d.shapes || DEFAULTS.shapes).split(',').map(function (n) { return n.trim(); })
.filter(function (n) { return /^(tetra|cube|octa|dodeca|icosa)$/.test(n); });
if (!names.length) return;
var canvas = document.createElement('canvas');
canvas.className = 'wfp-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var line = rgb(d.color || DEFAULTS.color);
var dots = d.dots === 'false' ? null : rgb(d.dots || DEFAULTS.dots);
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var w = 0, h = 0, dpr = 1, cells = [];
// Each solid turns about its own tilted axis at its own pace.
var items = names.map(function (n, i) {
return { s: solid(n), a: i * 1.3, b: 0.4 + i * 0.7, rate: 0.35 + (i % 3) * 0.12, tilt: 0.35 + (i % 2) * 0.3 };
});
var spin = { yaw: 0, v: 0, dragging: false };
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Pick the grid (columns × rows) that gives the biggest cells.
var n = items.length, best = 0, cols = 1;
for (var c = 1; c <= n; c++) {
var size = Math.min(w / c, h / Math.ceil(n / c));
if (size > best) { best = size; cols = c; }
}
var rows = Math.ceil(n / cols);
cells = items.map(function (it, i) {
var r = Math.floor(i / cols), inRow = Math.min(cols, n - r * cols), c = i % cols;
var cw = w / cols, ch = h / rows;
return { x: (w - inRow * cw) / 2 + cw * (c + 0.5), y: ch * (r + 0.5), r: best * 0.34 };
});
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.lineCap = 'round';
items.forEach(function (it, k) {
var cell = cells[k];
var ya = it.a + spin.yaw, cy = Math.cos(ya), sy = Math.sin(ya);
var xa = it.tilt + Math.sin(it.b) * 0.4, cx = Math.cos(xa), sx = Math.sin(xa);
var P = it.s.v.map(function (v) {
var x = v[0] * cy + v[2] * sy, z = -v[0] * sy + v[2] * cy;
var y = v[1] * cx - z * sx;
z = v[1] * sx + z * cx;
var p = 4 / (4 - z);
return { x: cell.x + x * p * cell.r, y: cell.y + y * p * cell.r, z: z };
});
var lw = Math.max(1, cell.r / 38);
it.s.e.map(function (e) { return [P[e[0]], P[e[1]]]; })
.sort(function (m, n) { return m[0].z + m[1].z - n[0].z - n[1].z; })
.forEach(function (e) {
var t = (e[0].z + e[1].z + 2) / 4; // 0 at the back, 1 at the front
ctx.strokeStyle = 'rgba(' + line + ',' + (0.18 + t * 0.82).toFixed(3) + ')';
ctx.lineWidth = lw * (0.6 + t * 1.1);
ctx.beginPath();
ctx.moveTo(e[0].x, e[0].y);
ctx.lineTo(e[1].x, e[1].y);
ctx.stroke();
});
if (!dots) return;
P.forEach(function (p) {
var t = (p.z + 1) / 2;
ctx.fillStyle = 'rgba(' + dots + ',' + (0.3 + t * 0.7).toFixed(3) + ')';
ctx.beginPath();
ctx.arc(p.x, p.y, lw * (0.9 + t * 1.3), 0, Math.PI * 2);
ctx.fill();
});
});
}
resize();
draw();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
if (d.drag !== 'false') {
el.style.cursor = 'grab';
var lx = 0, lt = 0;
el.addEventListener('pointerdown', function (e) {
spin.dragging = true;
spin.v = 0;
lx = e.clientX; lt = performance.now();
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
});
el.addEventListener('pointermove', function (e) {
if (!spin.dragging) return;
var now = performance.now(), dx = (e.clientX - lx) * 0.012;
spin.yaw += dx;
spin.v = dx / Math.max(8, now - lt) * 1000;
lx = e.clientX; lt = now;
draw();
});
var end = function () { spin.dragging = false; el.style.cursor = 'grab'; };
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
}
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) / 1000 : 0.016;
last = now;
items.forEach(function (it) { it.a += it.rate * speed * dt; it.b += 0.3 * speed * dt; });
if (!spin.dragging && spin.v) {
// Coast after a flick, slowing down.
spin.yaw += spin.v * dt;
spin.v *= Math.pow(0.05, dt);
if (Math.abs(spin.v) < 0.02) spin.v = 0;
}
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-wireframe-polyhedra]').forEach(function (el) {
if (el._wfp) return;
el._wfp = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-wireframe-polyhedra style="height: 320px"></div>
<!-- or just one big one: -->
<div data-wireframe-polyhedra data-shapes="icosa" style="height: 320px"></div>