3D Word Drum
The words sit round a real 3D drum that turns on its axis. The current word faces you, while the next and previous words curve away faintly above and below, squashed by the drum’s curve.
Think bigger
How to use it
- Paste the snippet just before
</head>. - Wrap the changing word in a
<span>and list every word, separated by commas, indata-word-drum.
Options
Add any of these attributes to the same element.
data-hold | How long each word stays. Default 1700 ms. |
data-duration | Time for each turn. Default 900 ms. |
data-peek | Opacity of the words above and below. Default 0.3; 0 hides them. |
data-direction | up (default) brings the next word up from below. down brings it down from above. |
The drum is as wide as the longest word, so the sentence never shifts. The neighbours peek outside the line, so leave a little space above and below.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// 3D Word Drum: the words sit round a real 3D drum that turns on its
// horizontal axis. The current word faces you, while the next and previous
// words curve away faintly above and below, foreshortened by the drum.
// Usage: <h1>Think <span data-word-drum="bigger, louder, sharper">bigger</span></h1>
// Optional: data-hold (ms per word, default 1700), data-duration (ms per
// turn, default 900), data-peek (opacity of the neighbouring words, default
// 0.3; 0 hides them), data-direction ("up", the default, brings the next
// word up from below; "down" brings it down from above).
var DEFAULTS = { hold: 1700, duration: 900, peek: 0.3 };
var css =
'.wdr{display:inline-grid;position:relative;vertical-align:baseline;text-align:left;perspective:9em}' +
'.wdr-size{grid-area:1/1;visibility:hidden;white-space:nowrap}' +
'.wdr-drum{position:absolute;inset:0;transform-style:preserve-3d}' +
'.wdr-face{position:absolute;left:0;top:0;width:100%;white-space:nowrap;backface-visibility:hidden;-webkit-backface-visibility:hidden;will-change:transform,opacity}' +
'.wdr-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function span(cls, parent, text) {
var s = document.createElement('span');
s.className = cls;
if (text) s.textContent = text;
parent.appendChild(s);
return s;
}
function setup(el, peek) {
var words = (el.getAttribute('data-word-drum') || el.textContent)
.split(',').map(function (w) { return w.trim(); }).filter(Boolean);
el.classList.add('wdr');
el.textContent = '';
span('wdr-sr', el, words.join(', '));
// Invisible copies of every word share one grid cell, so the drum is as
// wide as the longest word and sits on the sentence's baseline.
words.forEach(function (w) { span('wdr-size', el, w).setAttribute('aria-hidden', 'true'); });
var drum = span('wdr-drum', el);
drum.setAttribute('aria-hidden', 'true');
// A drum needs enough faces to look round: repeat the list to at least six.
var copies = Math.ceil(6 / words.length);
var faces = [];
for (var c = 0; c < copies; c++) {
words.forEach(function (w) { faces.push(span('wdr-face', drum, w)); });
}
var s = { el: el, words: words, faces: faces, pos: 0, peek: peek, radius: 0, visible: false, started: false };
measure(s);
render(s);
return s;
}
// The drum's radius is set so neighbouring faces just touch edge to edge.
function measure(s) {
var h = s.faces[0].offsetHeight || parseFloat(getComputedStyle(s.el).fontSize) * 1.2;
s.radius = (h / 2) / Math.tan(Math.PI / s.faces.length);
}
function render(s) {
var n = s.faces.length;
var step = 360 / n;
var sign = s.el.dataset.direction === 'down' ? -1 : 1;
var r = s.radius.toFixed(2);
s.faces.forEach(function (f, i) {
// Steps from the front, wrapped to -n/2..n/2.
var d = ((((i - s.pos) % n) + n + n / 2) % n) - n / 2;
var a = Math.abs(d);
f.style.transform = 'translateZ(-' + r + 'px) rotateX(' + (-d * step * sign).toFixed(2) + 'deg) translateZ(' + r + 'px)';
f.style.opacity = a < 1 ? (1 - a * (1 - s.peek)).toFixed(3) : Math.max(0, s.peek * (1.8 - a) / 0.8).toFixed(3);
});
}
function start(s) {
var d = s.el.dataset;
var step = 0;
var refit = function () { measure(s); render(s); };
window.addEventListener('resize', refit);
if (document.fonts) document.fonts.ready.then(refit);
refit();
(function next() {
setTimeout(function check() {
if (!s.visible || document.hidden) { setTimeout(check, 300); return; }
step++;
anime.animate(s, {
pos: step,
duration: +d.duration || DEFAULTS.duration,
ease: 'outBack(1.3)',
onUpdate: function () { render(s); },
onComplete: function () {
// Keep the number small so it never drifts.
if (step >= s.faces.length) { step -= s.faces.length; s.pos = step; render(s); }
next();
}
});
}, +d.hold || DEFAULTS.hold);
})();
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var s = e.target._wdr;
s.visible = e.isIntersecting;
if (s.visible && !s.started) { s.started = true; start(s); }
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-word-drum]').forEach(function (el) {
if (el._wdr) return;
var peek = el.dataset.peek !== undefined ? +el.dataset.peek : DEFAULTS.peek;
// Reduced motion: just the first word, without its neighbours.
el._wdr = setup(el, reduced ? 0 : peek);
if (!reduced && el._wdr.words.length > 1) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1>
Think
<span data-word-drum="bigger, louder, sharper">bigger</span>
</h1>