Fuzzy Vibrate
The letters buzz in place, each one jittering and leaving faint ghosts either side, like a guitar string that’s just been plucked. Bring the pointer close, or touch them, and they shake much harder.
Feeling fuzzy
How to use it
- Paste the snippet just before
</head>. - Add
data-fuzzy-vibrateto any heading or text element.
Options
Add any of these attributes to the same element.
data-intensity | How much every letter shakes at rest, in em. Default 0.012; 0 keeps them still until the pointer comes near. |
data-boost | Extra shake right under the pointer, in em. Default 0.07. |
data-radius | How far the pointer’s pull reaches, in em. Default 1.8. |
data-smear | One or two colours for the smear ghosts, separated by a comma. Default the text’s colour. |
data-autoplay | A pretend pointer sweeps along the text while no real one 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 () {
// Fuzzy Vibrate: the letters buzz in place, each one jittering and leaving
// a faint smear behind, like a string that has just been plucked. Letters
// near the pointer shake much harder.
// Usage: <h1 data-fuzzy-vibrate>Buzzing</h1>
// Optional: data-intensity (how much every letter shakes at rest, in em;
// default 0.012), data-boost (extra shake right under the pointer, in em;
// default 0.07), data-radius (how far the pointer's pull reaches, in em;
// default 1.8), data-smear (one or two comma-separated colours for the
// smear ghosts, e.g. "#ff3b6b,#22d3ee"; default the text colour),
// data-autoplay (a pretend pointer sweeps along the text while no real one
// is near; handy for demos and touch screens).
var DEFAULTS = { intensity: 0.012, boost: 0.07, radius: 1.8 };
var css =
'.fzv-word{display:inline-block;white-space:nowrap}' +
'.fzv-char{display:inline-block;will-change:transform,text-shadow}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var chars = [];
text.split(' ').forEach(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var word = document.createElement('span');
word.className = 'fzv-word';
word.setAttribute('aria-hidden', 'true');
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.className = 'fzv-char';
s.textContent = c;
word.appendChild(s);
chars.push({ el: s, f: 0, x: 0, y: 0 });
});
el.appendChild(word);
});
return chars;
}
function setup(el) {
var d = el.dataset;
var intensity = d.intensity !== undefined ? +d.intensity : DEFAULTS.intensity;
var boost = d.boost !== undefined ? +d.boost : DEFAULTS.boost;
var radiusEm = +d.radius || DEFAULTS.radius;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
var chars = split(el);
var smear = (d.smear || '').split(',').map(function (c) { return c.trim(); }).filter(Boolean);
var ghostA = smear[0] || 'currentColor';
var ghostB = smear[1] || ghostA;
if (reduced()) return;
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var fs = 16;
// Letter centres relative to the element, from layout (ignoring the jitter).
function measure() {
fs = parseFloat(getComputedStyle(el).fontSize);
chars.forEach(function (c) {
var s = c.el;
c.cx = s.offsetLeft + s.offsetWidth / 2;
c.cy = s.offsetTop + s.offsetHeight / 2;
});
}
measure();
new ResizeObserver(measure).observe(el);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure);
var pointer = null; // in element coordinates, or null when away
var lastReal = -1e9;
var clock = 0;
function onPointer(e) {
var r = el.getBoundingClientRect();
pointer = { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
lastReal = performance.now();
}
window.addEventListener('pointermove', onPointer, { passive: true });
window.addEventListener('pointerdown', onPointer, { passive: true });
window.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; }, { passive: true });
document.addEventListener('pointerleave', function () { pointer = null; });
function mix(color, pct) {
return 'color-mix(in srgb, ' + color + ' ' + pct + '%, transparent)';
}
var prev = 0, acc = 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;
acc += dt;
var p = pointer;
if (autoplay && now - lastReal > 2000 && chars.length) {
// Sweep back and forth along the line.
var first = chars[0], last = chars[chars.length - 1];
var k = (1 - Math.cos(clock * 0.0011)) / 2;
p = { x: first.cx + (last.cx - first.cx) * k, y: first.cy + (last.cy - first.cy) * k };
}
var radius = radiusEm * fs;
var jitter = acc >= 33; // new shakes about 30 times a second
if (jitter) acc = 0;
chars.forEach(function (c) {
var target = 0;
if (p) {
var dx = c.cx - p.x, dy = (c.cy - p.y) * 0.7;
var dist = Math.sqrt(dx * dx + dy * dy);
target = dist < radius ? (1 + Math.cos(Math.PI * dist / radius)) / 2 : 0;
}
c.f += (target - c.f) * (1 - Math.pow(0.85, dt / 16));
if (!jitter) return;
var amp = (intensity + boost * c.f) * fs;
var nx = (Math.random() * 2 - 1) * amp;
var ny = (Math.random() * 2 - 1) * amp * 0.45;
var s = c.el.style;
s.transform = 'translate(' + nx.toFixed(2) + 'px,' + ny.toFixed(2) + 'px)';
// Ghosts trail either side of the shake direction.
var g = Math.max(Math.abs(nx), fs * 0.006);
var blur = (0.3 * g + c.f * fs * 0.02).toFixed(2);
var alpha = Math.round(28 + 30 * c.f);
s.textShadow =
(-nx * 1.6).toFixed(2) + 'px ' + (-ny).toFixed(2) + 'px ' + blur + 'px ' + mix(ghostA, alpha) + ',' +
(nx * 1.2 + (nx < 0 ? -g : g) * 0.6).toFixed(2) + 'px ' + (ny * 0.5).toFixed(2) + 'px ' + blur + 'px ' + mix(ghostB, Math.round(alpha * 0.7));
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { prev = 0; ticker.play(); }
else ticker.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-fuzzy-vibrate]').forEach(function (el) {
if (el._fzv) return;
el._fzv = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-fuzzy-vibrate>Feeling fuzzy</h1>
<!-- red and cyan smears, still until touched: -->
<h1 data-fuzzy-vibrate data-intensity="0" data-smear="#ff3b6b,#22d3ee">Feeling fuzzy</h1>