Echo Settle
The text slides in trailed by a string of fading, ghosted copies of itself. The echoes lag a little further behind each one, then catch up and merge into the one solid word.
Echo settle
How to use it
- Paste the snippet just before
</head>. - Add
data-echo-settleto any heading or text element.
Options
Add any of these attributes to the same element.
data-color | The colour of the echoes. Default #a855f7. |
data-echoes | How many ghosted copies trail behind. Default 4. |
data-from | Where it slides in from: left (default), right, top or bottom. |
data-distance | How far it travels, in em. Default 1.2. |
data-duration | Time to settle. Default 1100 ms. |
data-loop | Settle, hold, slide out the other side and in again. |
data-hold | How long the text stays before leaving, when looping. Default 1800 ms. |
data-delay | Wait before starting. Default 0 ms. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Echo Settle: the text slides in trailed by fading, ghosted copies of
// itself, which catch up and merge into the one solid word.
// Usage: <h1 data-echo-settle>Echo</h1>
// Optional: data-color (the ghosts' colour, default "#a855f7"), data-echoes
// (how many ghosts, default 4), data-from (left, right, top or bottom,
// default left), data-distance (how far it travels, in em, default 1.2),
// data-duration (ms, default 1100), data-loop (slide out and in again),
// data-hold (ms to stay before leaving, when looping), data-delay (ms).
var DEFAULTS = { color: '#a855f7', echoes: 4, from: 'left', distance: 1.2, duration: 1100, hold: 1800, delay: 0 };
var css =
'.ecs-on{position:relative;isolation:isolate}' +
'.ecs-main{display:block;position:relative;z-index:1}' +
'.ecs-ghost{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;filter:blur(.4px)}';
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, cs = getComputedStyle(el);
var text = el.textContent.replace(/\s+/g, ' ').trim();
var n = Math.max(1, Math.min(8, +d.echoes || DEFAULTS.echoes));
el.setAttribute('aria-label', text);
el.textContent = '';
el.classList.add('ecs-on');
var ghosts = [];
for (var i = 0; i < n; i++) {
var g = document.createElement('span');
g.className = 'ecs-ghost';
g.setAttribute('aria-hidden', 'true');
g.textContent = text;
g.style.color = d.color || DEFAULTS.color;
g.style.padding = cs.padding;
el.appendChild(g);
ghosts.push(g);
}
var main = document.createElement('span');
main.className = 'ecs-main';
main.setAttribute('aria-hidden', 'true');
main.textContent = text;
el.appendChild(main);
return { main: main, ghosts: ghosts };
}
// The offset (in px) for a travelled fraction `k` of the distance.
function axis(el) {
var from = el.dataset.from || DEFAULTS.from;
var dist = (+el.dataset.distance || DEFAULTS.distance) * parseFloat(getComputedStyle(el).fontSize);
var sign = from === 'right' || from === 'bottom' ? 1 : -1;
return { prop: from === 'top' || from === 'bottom' ? 'y' : 'x', dist: sign * dist };
}
function tween(target, prop, from, to, extra) {
var o = {};
o[prop] = [from, to];
for (var k in extra) o[k] = extra[k];
return o;
}
// dir 1 = arrive, -1 = leave (towards the other side).
function move(el, dir, done) {
var s = el._ecs, d = el.dataset, a = axis(el);
var duration = +d.duration || DEFAULTS.duration;
var tl = anime.createTimeline({ onComplete: done });
var n = s.ghosts.length;
var start = dir > 0 ? a.dist : 0, end = dir > 0 ? 0 : -a.dist;
tl.add(s.main, tween(s.main, a.prop, start, end, {
opacity: dir > 0 ? [0, 1] : [1, 0],
duration: dir > 0 ? duration : duration * 0.7,
ease: dir > 0 ? 'outExpo' : 'inQuart'
}), 0);
s.ghosts.forEach(function (g, i) {
var lag = (i + 1) / n;
var dur = (dir > 0 ? duration : duration * 0.7) * (1 + lag * 0.6);
var peak = 0.55 * (1 - i / (n + 1));
tl.add(g, tween(g, a.prop, start, end, {
duration: dur,
delay: lag * 90,
ease: dir > 0 ? 'outExpo' : 'inQuart'
}), 0);
tl.add(g, { opacity: [0, peak], duration: 180, ease: 'outQuad' }, lag * 90);
tl.add(g, { opacity: [peak, 0], duration: dur * 0.6, ease: 'inQuad' }, lag * 90 + dur * 0.4);
});
}
function play(el) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
move(el, 1, function () {
if (!loop) return;
setTimeout(function () {
move(el, -1, function () { setTimeout(function () { play(el); }, 300); });
}, +d.hold || DEFAULTS.hold);
});
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
var el = e.target;
setTimeout(function () { play(el); }, +el.dataset.delay || DEFAULTS.delay);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-echo-settle]').forEach(function (el) {
if (el._ecs) return;
el._ecs = setup(el);
if (reduced()) return;
el._ecs.main.style.opacity = 0;
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-echo-settle>Echo settle</h1>
<!-- or, dropping in from above with pink echoes: -->
<h1 data-echo-settle data-from="top" data-color="#ec4899">Echo settle</h1>