3D Tag Sphere
Words sit evenly over the surface of a sphere that turns slowly in space; the ones round the back shrink and fade. Drag or flick it to spin it, and with a mouse it leans towards wherever you point.
CSS
SVG
Canvas
anime.js
Easing
Motion
HTML
Keyframes
WebGL
Scroll
Hover
Spring
Bezier
Transform
Opacity
Stagger
Timeline
Loop
3D
Blur
Gradient
Path
Morph
Delay
How to use it
- Paste the snippet just before
</head>. - Add
data-tag-sphereto a box with a height. Each child is a tag: words, links or icons.
Options
Add any of these attributes to the same element.
data-radius | Radius of the sphere in px. Default fits the box. |
data-speed | How fast it turns when left alone, in degrees per second. Default 14. |
data-follow="false" | Don’t lean towards the mouse pointer. |
data-drag="false" | Don’t let people spin it by dragging. |
Links on the front half stay clickable; a drag doesn’t count as a click. Between about 15 and 40 tags looks best.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// 3D Tag Sphere: words or icons sit evenly over a sphere that turns
// slowly. The ones at the back shrink and fade. Drag it (or flick it) to
// spin it; with a mouse it also leans towards the pointer.
// Usage:
// <div data-tag-sphere style="height: 360px">
// <a href="/css">CSS</a> <a href="/svg">SVG</a> <span>Canvas</span> …
// </div>
// The children are the tags; links stay clickable.
// Optional: data-radius (px, default fits the box), data-speed (degrees per
// second when idle, default 14), data-follow="false" (don't lean towards
// the pointer), data-drag="false".
var DEFAULTS = { speed: 14 };
var css =
'.tgs{position:relative;overflow:hidden;touch-action:pan-y;user-select:none;-webkit-user-select:none}' +
'.tgs-drag{cursor:grab}.tgs-drag:active{cursor:grabbing}' +
'.tgs-tag{position:absolute;left:50%;top:50%;white-space:nowrap;will-change:transform,opacity}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Rotate point p about the X axis by a, then the Y axis by b.
function turn(p, a, b) {
var ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b);
var y = p[1] * ca - p[2] * sa, z = p[1] * sa + p[2] * ca;
var x = p[0] * cb + z * sb;
z = -p[0] * sb + z * cb;
return [x, y, z];
}
function setup(el) {
var d = el.dataset;
var tags = Array.from(el.children);
if (!tags.length) return;
el.classList.add('tgs');
var n = tags.length;
// Even spacing over the sphere: a Fibonacci spiral.
var pts = tags.map(function (t, i) {
t.classList.add('tgs-tag');
var y = 1 - (2 * i + 1) / n;
var rr = Math.sqrt(1 - y * y), th = i * Math.PI * (3 - Math.sqrt(5));
return [Math.cos(th) * rr, y, Math.sin(th) * rr];
});
var sizes = [];
var speed = (d.speed !== undefined ? +d.speed : DEFAULTS.speed) * Math.PI / 180;
var R = 0;
// Spin velocity in radians per second, about X (vx) and Y (vy).
var s = { vx: speed * 0.25, vy: speed, dragging: false };
var lean = null;
function size() {
R = +d.radius || Math.min(el.clientWidth, el.clientHeight) * 0.38;
sizes = tags.map(function (t) { return [t.offsetWidth, t.offsetHeight]; });
draw();
}
function draw() {
for (var i = 0; i < n; i++) {
var p = pts[i];
var k = (p[2] + 2) / 3; // 1/3 at the back, 1 at the front
var sc = 0.55 + 0.6 * k;
var t = tags[i];
t.style.transform = 'translate(' + (p[0] * R - sizes[i][0] / 2) + 'px,' + (p[1] * R - sizes[i][1] / 2) + 'px) scale(' + sc.toFixed(3) + ')';
t.style.opacity = (0.15 + 0.85 * k * k).toFixed(3);
t.style.zIndex = Math.round(k * 100);
// Only the front half takes clicks.
t.style.pointerEvents = p[2] > -0.1 ? '' : 'none';
}
}
function rotate(ax, ay) {
for (var i = 0; i < n; i++) {
var q = turn(pts[i], ax, ay);
var len = Math.sqrt(q[0] * q[0] + q[1] * q[1] + q[2] * q[2]); // keep it round
pts[i] = [q[0] / len, q[1] / len, q[2] / len];
}
}
size();
if (window.ResizeObserver) new ResizeObserver(size).observe(el);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(size);
if (reduced()) return;
var hover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
if (d.follow !== 'false' && hover) {
el.addEventListener('pointermove', function (e) {
if (s.dragging || e.pointerType !== 'mouse') return;
var r = el.getBoundingClientRect();
lean = [(e.clientX - r.left) / r.width - 0.5, (e.clientY - r.top) / r.height - 0.5];
});
el.addEventListener('pointerleave', function () { lean = null; });
}
if (d.drag !== 'false') {
el.classList.add('tgs-drag');
var lx = 0, ly = 0, lt = 0, moved = 0;
el.addEventListener('pointerdown', function (e) {
if (e.button) return;
s.dragging = true;
moved = 0;
lx = e.clientX; ly = e.clientY; lt = performance.now();
});
el.addEventListener('pointermove', function (e) {
if (!s.dragging) return;
var now = performance.now(), dx = e.clientX - lx, dy = e.clientY - ly;
moved += Math.abs(dx) + Math.abs(dy);
if (moved > 4 && !el.hasPointerCapture(e.pointerId)) el.setPointerCapture(e.pointerId);
var ay = dx / R, ax = -dy / R;
rotate(ax, ay);
var dt = Math.max(8, now - lt) / 1000;
s.vy = ay / dt; s.vx = ax / dt;
lx = e.clientX; ly = e.clientY; lt = now;
draw();
});
var end = function () { s.dragging = false; };
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
// A drag isn't a click on the tag under it.
el.addEventListener('click', function (e) { if (moved > 4) e.preventDefault(); }, true);
}
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;
if (s.dragging) return;
// Ease towards the idle spin, or towards the pointer if it's over.
var tx = speed * 0.25, ty = speed;
if (lean) { ty = lean[0] * speed * 6; tx = -lean[1] * speed * 6; }
var k = Math.min(1, dt * 1.5);
s.vx += (tx - s.vx) * k;
s.vy += (ty - s.vy) * k;
rotate(s.vx * dt, s.vy * dt);
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-tag-sphere]').forEach(function (el) {
if (el._tgs) return;
el._tgs = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-tag-sphere style="height: 360px">
<a href="/css">CSS</a>
<a href="/svg">SVG</a>
<a href="/canvas">Canvas</a>
<!-- … -->
</div>