Perspective Text Tunnel
Copies of a word appear far away in the middle of the box and stream towards you, growing, brightening and turning a little as they come, until they fly past the edges. An endless tunnel built from one word.
DEEPER
How to use it
- Paste the snippet just before
</head>. - Add
data-perspective-text-tunnelto a box holding one short word, and give the box a height and a dark background.
Options
Add any of these attributes to the same element.
data-copies | How many copies are in the tunnel at once. Default 9. |
data-speed | Seconds for a copy to travel from the far end to past you. Default 6. |
data-twist | Degrees each copy is turned from the one behind it, which makes the tunnel spiral. Default 5; 0 for straight. |
data-style | outline (default) or solid letters. |
data-color | Colour of the nearest copies. Default the text colour. |
data-far | Colour of the distant copies. Default #a855f7. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Perspective Text Tunnel: copies of a word appear far away in the middle
// of the box and stream towards you, growing and turning slightly as they
// come, then fly past the edges. An endless tunnel made of one word.
// Usage: <div data-perspective-text-tunnel style="height:320px">Deeper</div>
// The element is the tunnel: give it a height and a background. Its font
// size sets the copies' size; they grow to about three times that as they
// fly past.
// Optional: data-copies (how many copies are in the tunnel at once; default 9),
// data-speed (seconds for a copy to travel the whole way; default 6),
// data-twist (degrees each copy is turned from the one behind; default 5),
// data-style ("outline", the default, or "solid"), data-color (colour of
// the nearest copies; default the text colour), data-far (colour of the
// distant copies; default #a855f7).
var DEFAULTS = { copies: 9, speed: 6, twist: 5, far: '#a855f7' };
var css =
'.ptt-box{position:relative;overflow:hidden}' +
'.ptt-copy{position:absolute;left:50%;top:50%;white-space:nowrap;line-height:1;pointer-events:none}' +
'.ptt-outline .ptt-copy{color:transparent;-webkit-text-stroke:var(--ptt-stroke,2px) var(--ptt-c)}' +
'.ptt-solid .ptt-copy{color:var(--ptt-c)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(color) {
var probe = document.createElement('span');
probe.style.color = color;
document.body.appendChild(probe);
var m = (getComputedStyle(probe).color.match(/[\d.]+/g) || [0, 0, 0]).map(Number);
probe.remove();
return m;
}
function setup(el) {
var d = el.dataset;
var n = Math.max(2, +d.copies || DEFAULTS.copies);
var speed = +d.speed || DEFAULTS.speed;
var twist = d.twist !== undefined ? +d.twist : DEFAULTS.twist;
var solid = d.style === 'solid';
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.setAttribute('role', 'img');
el.textContent = '';
el.classList.add('ptt-box', solid ? 'ptt-solid' : 'ptt-outline');
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var near = rgb(d.color || getComputedStyle(el).color);
var far = rgb(d.far || DEFAULTS.far);
var copies = [];
for (var i = 0; i < n; i++) {
var s = document.createElement('span');
s.className = 'ptt-copy';
s.setAttribute('aria-hidden', 'true');
s.textContent = text;
el.appendChild(s);
copies.push(s);
}
var fs = 16;
function measure() {
fs = parseFloat(getComputedStyle(el).fontSize);
el.style.setProperty('--ptt-stroke', Math.max(1, fs / 36).toFixed(1) + 'px');
if (el.clientHeight < fs * 3) el.style.minHeight = (fs * 4) + 'px';
}
measure();
new ResizeObserver(measure).observe(el);
// p runs 0 (far away) to 1 (flown past). Depth from 12 units to 0.35.
var FAR = 12, NEAR = 0.35;
function draw(t) {
for (var i = 0; i < n; i++) {
var p = ((t + i / n) % 1 + 1) % 1;
var dist = FAR * Math.pow(NEAR / FAR, p); // evenly spaced rings
var scale = 1 / dist;
// Fade in from the vanishing point, out as it flies past.
var op = Math.min(1, p / 0.18) * Math.min(1, (1 - p) / 0.12);
var k = Math.min(1, scale); // how near: 0 far, 1 at full size
var mix = Math.pow(k, 0.6);
var c = [0, 1, 2].map(function (j) { return Math.round(far[j] + (near[j] - far[j]) * mix); });
var rot = -(1 - p) * twist * n;
var s = copies[i].style;
s.transform = 'translate(-50%,-50%) rotate(' + rot.toFixed(2) + 'deg) scale(' + scale.toFixed(4) + ')';
s.opacity = (op * (0.35 + 0.65 * mix)).toFixed(3);
s.zIndex = Math.round(p * 1000);
s.setProperty('--ptt-c', 'rgb(' + c.join(',') + ')');
}
}
var t = 0.37;
draw(t);
if (reduced()) return;
var running = false, raf = 0, last = 0;
function frame(now) {
if (!running) return;
var dt = Math.min(50, now - last); last = now;
t = (t + dt / (speed * 1000)) % 1;
draw(t);
raf = requestAnimationFrame(frame);
}
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
if (!running) { running = true; last = performance.now(); raf = requestAnimationFrame(frame); }
} else {
running = false; cancelAnimationFrame(raf);
}
}).observe(el);
}
function init() {
document.querySelectorAll('[data-perspective-text-tunnel]').forEach(function (el) {
if (el._ptt) return;
el._ptt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-perspective-text-tunnel style="height: 360px; background: #0b0618; color: #fff">DEEPER</div>
<!-- solid letters, no spiral: -->
<div data-perspective-text-tunnel data-style="solid" data-twist="0" style="height: 360px">DEEPER</div>