Smoke Reveal
Letters condense out of drifting smoke, one by one. Each arrives as a swirl of soft haze that blows in from the side, draws together and sharpens into the letter while the last wisps drift away.
Out of thin air
How to use it
- Paste the snippet just before
</head>. - Add
data-smoke-revealto any heading or text element.
Options
Add any of these attributes to the same element.
data-color | The smoke’s colour. Default the text colour. |
data-drift | Which way the smoke blows: right (default) or left. |
data-loop | Condense, hold, blow away into smoke and condense again. |
data-hold | How long the text stays before blowing away, when looping. Default 1800 ms. |
data-duration | How long each letter takes to condense. Default 1700 ms. |
data-stagger | Gap between letters. Default 70 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 () {
// Smoke Reveal: letters condense out of drifting smoke, one by one. Each
// letter arrives as a swirl of soft haze that blows in from one side, draws
// together and sharpens into the letter while the last wisps drift away.
// Usage: <h1 data-smoke-reveal>Out of thin air</h1>
// Optional: data-color (the smoke, default the text colour), data-drift
// (which way the smoke blows: "right", the default, or "left"), data-loop
// (blow away into smoke and condense again), data-hold (ms to stay before
// blowing away), data-duration (ms per letter), data-stagger (ms between
// letters), data-delay (ms before starting).
var DEFAULTS = { drift: 'right', duration: 1700, stagger: 70, hold: 1800, delay: 0 };
var css =
'.smk-word{display:inline-block;white-space:nowrap}' +
'.smk-char{display:inline-block;position:relative}' +
'.smk-glyph{display:inline-block;will-change:transform,filter,opacity}' +
'.smk-puff{position:absolute;left:50%;top:50%;width:1em;height:1em;margin:-.5em 0 0 -.5em;' +
'border-radius:50%;pointer-events:none;opacity:0;will-change:transform,opacity}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var R = function (a, b) { return a + Math.random() * (b - a); };
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function setup(el) {
var d = el.dataset;
var text = el.textContent.replace(/\s+/g, ' ').trim();
var color = d.color || 'currentColor';
el.setAttribute('aria-label', text);
el.textContent = '';
var glyphs = [], puffs = [];
text.split(' ').forEach(function (word, i) {
if (i) el.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'smk-word';
w.setAttribute('aria-hidden', 'true');
Array.from(word).forEach(function (c) {
var ch = document.createElement('span');
ch.className = 'smk-char';
var g = document.createElement('span');
g.className = 'smk-glyph';
g.textContent = c;
ch.appendChild(g);
var mine = [];
// Two soft puffs of haze per letter.
for (var k = 0; k < 2; k++) {
var p = document.createElement('span');
p.className = 'smk-puff';
p.style.background = 'radial-gradient(closest-side,' + color + ',transparent)';
ch.appendChild(p);
mine.push(p);
}
w.appendChild(ch);
glyphs.push(g);
puffs.push(mine);
});
el.appendChild(w);
});
return { glyphs: glyphs, puffs: puffs };
}
function play(el) {
var d = el.dataset, s = el._smk;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var stagger = +d.stagger || DEFAULTS.stagger;
var dir = (d.drift || DEFAULTS.drift) === 'left' ? -1 : 1;
var fs = parseFloat(getComputedStyle(el).fontSize);
var blur = (fs * 0.22).toFixed(1);
var tl = anime.createTimeline({
onComplete: function () {
if (!loop) return;
setTimeout(function () { away(); }, +d.hold || DEFAULTS.hold);
}
});
s.glyphs.forEach(function (g, i) {
var t = i * stagger;
tl.add(g, {
opacity: [0, 1],
x: [-dir * fs * R(0.25, 0.45), 0],
y: [fs * R(-0.12, 0.12), 0],
scale: [1.45, 1],
rotate: [dir * R(-14, 4), 0],
filter: ['blur(' + blur + 'px)', 'blur(0px)'],
duration: duration,
ease: 'out(3)'
}, t);
s.puffs[i].forEach(function (p, k) {
tl.add(p, {
opacity: [0, 0.26, 0],
x: [-dir * fs * R(0.5, 0.8), dir * fs * R(0.2, 0.6)],
y: [fs * R(0, 0.25), -fs * R(0.15, 0.55)],
scale: [R(0.4, 0.7), R(1.6, 2.3)],
duration: duration * R(1, 1.3),
ease: 'out(2)'
}, Math.max(0, t - 120 + k * 160));
});
});
function away() {
var out = anime.createTimeline({ onComplete: function () { setTimeout(function () { play(el); }, 300); } });
s.glyphs.forEach(function (g, i) {
var t = i * stagger * 0.5;
out.add(g, {
opacity: 0,
x: dir * fs * R(0.2, 0.4),
y: -fs * R(0.05, 0.2),
scale: 1.3,
filter: 'blur(' + blur + 'px)',
duration: duration * 0.6,
ease: 'in(2)'
}, t);
out.add(s.puffs[i][0], {
opacity: [0, 0.2, 0],
x: [0, dir * fs * 0.7],
y: [0, -fs * 0.4],
scale: [0.8, 2],
duration: duration * 0.8,
ease: 'out(2)'
}, t + 100);
});
}
}
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-smoke-reveal]').forEach(function (el) {
if (el._smk) return;
el._smk = setup(el);
if (reduced()) return;
el._smk.glyphs.forEach(function (g) { g.style.opacity = 0; });
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-smoke-reveal>Out of thin air</h1>
<!-- or, with grey smoke blowing left, looping: -->
<h1 data-smoke-reveal data-color="#9ca3af" data-drift="left" data-loop>Out of thin air</h1>