Depth Text
A stack of coloured copies sits behind the text and slides the opposite way to the pointer, so the word seems to float above a fan of shadows. On phones, and whenever the pointer is still, it drifts round by itself.
Look deeper
How to use it
- Paste the snippet just before
</head>. - Add
data-depth-textto a heading. Move the pointer anywhere on the page to steer it.
Options
Add any of these attributes to the same element.
data-layers | How many copies sit behind the text. Default 5. |
data-depth | How far the back copy travels. Default 0.22 em. |
data-color | Colour of the copy just behind. Default #a855f7. |
data-fade | Colour of the furthest copy. Default #e9d5ff. |
data-outline | Draw the copies as outlines instead of solid colour. |
data-autoplay | Drift round by itself whenever no real pointer is moving. Handy for demos and touch screens. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Depth Text: layered copies of the text sit behind it and slide against
// the pointer, so the word seems to float above a stack of shadows.
// Usage: <h1 data-depth-text>Look deeper</h1>
// Optional: data-layers (copies behind the text; default 5), data-depth
// (how far the back copy travels, in em; default 0.22), data-color (colour
// of the copy just behind; default #a855f7), data-fade (colour of the
// furthest copy; default #e9d5ff), data-outline (draw the copies as outlines
// instead of solid), data-autoplay (a pretend pointer drifts around while no
// real one is moving; handy for demos and touch screens).
var DEFAULTS = { layers: 5, depth: 0.22, color: '#a855f7', fade: '#e9d5ff' };
var css =
'.dpt-wrap{position:relative;display:inline-block}' +
'.dpt-front{position:relative;display:block;will-change:transform}' +
'.dpt-layer{position:absolute;left:0;top:0;width:100%;height:100%;display:block;will-change:transform}' +
'.dpt-outline .dpt-layer{color:transparent!important;-webkit-text-stroke:.025em var(--dpt-c)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function setup(el) {
var d = el.dataset;
var n = Math.max(1, Math.min(12, +d.layers || DEFAULTS.layers));
var depth = d.depth !== undefined ? +d.depth : DEFAULTS.depth;
var near = d.color || DEFAULTS.color;
var far = d.fade || DEFAULTS.fade;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var wrap = document.createElement('span');
wrap.className = 'dpt-wrap';
wrap.setAttribute('aria-hidden', 'true');
if (d.outline !== undefined && d.outline !== 'false') wrap.classList.add('dpt-outline');
var layers = [];
for (var i = n; i >= 1; i--) {
var s = document.createElement('span');
s.className = 'dpt-layer';
s.textContent = text;
var c = 'color-mix(in srgb, ' + far + ' ' + Math.round(100 * (i - 1) / Math.max(1, n - 1)) + '%, ' + near + ')';
s.style.color = c;
s.style.setProperty('--dpt-c', c);
wrap.appendChild(s);
layers.unshift({ el: s, k: i / n });
}
var front = document.createElement('span');
front.className = 'dpt-front';
front.textContent = text;
wrap.appendChild(front);
el.appendChild(wrap);
var fs = 16;
function measure() { fs = parseFloat(getComputedStyle(el).fontSize); }
measure();
new ResizeObserver(measure).observe(el);
// Where the layers sit for a pointer offset (ox, oy), each from -1 to 1.
function place(ox, oy) {
var far = depth * fs;
front.style.transform = 'translate(' + (ox * far * 0.25).toFixed(2) + 'px,' + (oy * far * 0.25).toFixed(2) + 'px)';
layers.forEach(function (l) {
l.el.style.transform = 'translate(' + (-ox * far * l.k).toFixed(2) + 'px,' + (-oy * far * l.k).toFixed(2) + 'px)';
});
}
if (reduced()) { place(0.6, 0.6); return; }
var target = { x: 0.6, y: 0.6 };
var cur = { x: 0, y: 0 };
var lastReal = -1e9;
var clock = 0;
function onPointer(e) {
var r = el.getBoundingClientRect();
if (r.bottom < 0 || r.top > innerHeight) return;
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
var reach = Math.max(160, Math.min(r.width, innerWidth) * 0.6);
target.x = Math.max(-1, Math.min(1, (e.clientX - cx) / reach));
target.y = Math.max(-1, Math.min(1, (e.clientY - cy) / reach));
lastReal = performance.now();
}
window.addEventListener('pointermove', onPointer, { passive: true });
window.addEventListener('pointerdown', onPointer, { passive: true });
var prev = 0;
var ticker = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = prev ? Math.min(50, now - prev) : 16;
prev = now;
clock += dt;
if (autoplay && now - lastReal > 2000) {
// A slow figure-of-eight wander.
target.x = Math.sin(clock * 0.0011) * 0.95;
target.y = Math.sin(clock * 0.0019 + 1) * 0.75;
}
var ease = 1 - Math.pow(0.9, dt / 16);
cur.x += (target.x - cur.x) * ease;
cur.y += (target.y - cur.y) * ease;
place(cur.x, cur.y);
}
});
place(0, 0);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
else ticker.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-depth-text]').forEach(function (el) {
if (el._dpt) return;
el._dpt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-depth-text data-autoplay>Look deeper</h1>
<!-- outlined copies in blue: -->
<h1 data-depth-text data-outline data-color="#2563eb" data-fade="#bfdbfe">Look deeper</h1>