Stretchy Headline
Grab the headline anywhere and pull. The letters under your finger follow it while their neighbours stretch and lean to keep up, like a rubber band, and the further you pull the harder it resists. Let go and it snaps back with a wobble.
Pull me about
How to use it
- Paste the snippet just before
</head>. - Add
data-stretchy-headlineto a heading.
Options
Add any of these attributes to the same element.
data-reach | How far along the line the pull spreads, in em. Default 2. |
data-max | How far it can stretch before it stops giving, in em. Default 1.6. |
data-autoplay | A pretend hand gives it a tug every few seconds while nobody is touching it. |
data-interval | Time between autoplay tugs. Default 2800 ms. |
Works with a mouse or a finger. While a finger is on the headline it stretches instead of scrolling the page, so keep it to a heading rather than a long paragraph.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Stretchy Headline: grab the headline anywhere and pull. The letters near
// your finger follow it and the ones either side stretch and lean to keep
// up, like a rubber band; let go and it snaps back with a wobble.
// Usage: <h1 data-stretchy-headline>Pull me</h1>
// Optional: data-reach (how far along the line the pull spreads, in em;
// default 2), data-max (the furthest it stretches, in em; default 1.6),
// data-autoplay (a pretend hand gives it a tug every few seconds while
// nobody is touching it), data-interval (ms between autoplay tugs;
// default 2800).
var DEFAULTS = { reach: 2, max: 1.6, interval: 2800 };
var css =
'.sty-box{cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none}' +
'.sty-box.sty-held{cursor:grabbing}' +
'.sty-word{display:inline-block;white-space:nowrap}' +
'.sty-ch{display:inline-block;will-change:transform}';
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 = 'sty-word';
word.setAttribute('aria-hidden', 'true');
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.className = 'sty-ch';
s.textContent = c;
word.appendChild(s);
chars.push({ el: s });
});
el.appendChild(word);
});
return chars;
}
function setup(el) {
var d = el.dataset;
var reach = +d.reach || DEFAULTS.reach;
var maxPull = +d.max || DEFAULTS.max;
var interval = +d.interval || DEFAULTS.interval;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var chars = split(el);
if (reduced() || !chars.length) return;
el.classList.add('sty-box');
var fs = 16;
function measure() {
fs = parseFloat(getComputedStyle(el).fontSize);
chars.forEach(function (c) {
c.cx = c.el.offsetLeft + c.el.offsetWidth / 2;
c.cy = c.el.offsetTop + c.el.offsetHeight / 2;
});
}
measure();
new ResizeObserver(measure).observe(el);
// The grab: where it started and how far it has been pulled.
var pull = { gx: 0, gy: 0, dx: 0, dy: 0 };
// Soft limit: the further you pull, the harder it resists.
function soft(v, m) { return m * Math.tanh(v / m); }
function render() {
var sigma = reach * fs, m = maxPull * fs;
var dx = soft(pull.dx, m), dy = soft(pull.dy, m);
chars.forEach(function (c) {
var ox = c.cx - pull.gx, oy = (c.cy - pull.gy) * 0.6;
var dist2 = ox * ox + oy * oy;
var w = Math.exp(-dist2 / (2 * sigma * sigma));
// Slope of the bump along the line: how much this letter is stretched.
var dw = -ox / (sigma * sigma) * w;
var tx = dx * w, ty = dy * w;
var sx = Math.max(0.6, 1 + dx * dw);
var lean = Math.atan(dy * dw) * 0.9;
var sy = 1 + Math.abs(dy) / fs * w * 0.12;
c.el.style.transform = 'translate(' + tx.toFixed(1) + 'px,' + ty.toFixed(1) + 'px) rotate(' + lean.toFixed(3) + 'rad) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')';
});
}
var spring = null;
function snap() {
if (spring) spring.pause();
spring = anime.animate(pull, { dx: 0, dy: 0, duration: 1300, ease: 'outElastic(1, .35)', onUpdate: render });
}
function local(e) {
var r = el.getBoundingClientRect();
return { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
}
var held = false, sx0 = 0, sy0 = 0, lastReal = -1e9;
el.addEventListener('pointerdown', function (e) {
e.preventDefault();
held = true; lastReal = performance.now();
if (spring) spring.pause();
if (fake) { fake.pause(); fake = null; }
var p = local(e);
// Grabbing a still-wobbling line carries on from where it is.
pull.gx = p.x; pull.gy = p.y; sx0 = p.x - pull.dx; sy0 = p.y - pull.dy;
el.classList.add('sty-held');
try { el.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
});
el.addEventListener('pointermove', function (e) {
if (!held) return;
lastReal = performance.now();
var p = local(e);
pull.dx = p.x - sx0; pull.dy = p.y - sy0;
render();
});
function letGo() {
if (!held) return;
held = false; lastReal = performance.now();
el.classList.remove('sty-held');
snap();
}
el.addEventListener('pointerup', letGo);
el.addEventListener('pointercancel', letGo);
if (!autoplay) return;
var fake = null, timer = null;
function tug() {
if (!held && performance.now() - lastReal > 4000 && chars.length) {
var c = chars[Math.floor(Math.random() * chars.length)];
if (spring) spring.pause();
pull.gx = c.cx; pull.gy = c.cy; pull.dx = 0; pull.dy = 0;
var ang = anime.utils.random(0, 360) * Math.PI / 180;
if (c.cx < el.clientWidth * 0.3) ang = anime.utils.random(-60, 60) * Math.PI / 180 + Math.PI;
else if (c.cx > el.clientWidth * 0.7) ang = anime.utils.random(-60, 60) * Math.PI / 180;
var len = maxPull * fs * anime.utils.random(1.1, 1.8, 2);
fake = anime.animate(pull, {
dx: Math.cos(ang) * len, dy: Math.sin(ang) * len * 0.8,
duration: 650, ease: 'out(3)', onUpdate: render,
onComplete: function () { fake = null; setTimeout(function () { if (!held) snap(); }, 120); }
});
}
timer = setTimeout(tug, interval);
}
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tug, 600);
}).observe(el);
}
function init() {
document.querySelectorAll('[data-stretchy-headline]').forEach(function (el) {
if (el._sty) return;
el._sty = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-stretchy-headline>Pull me about</h1>
<!-- a looser band that reaches further: -->
<h1 data-stretchy-headline data-reach="4" data-max="2.4">Pull me about</h1>