morphiq
componentelastic-headlinesource: ok · hit
Elastic Headline
A headline strung on an elastic. Letters are nodes on a mass-spring chain pinned at both ends and coupled by the discrete Laplacian, so a pull travels outward and reflects off the ends — grab one and the whole line deforms, then rings out.
npx shadcn add "https://morphiq.prathampatel.design/r/elastic-headline.json"free
Interaction
text-animation
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files4 · source cached 10/9/2026, 2:41:09 PM
src/components/ElasticHeadline/index.js
export { default } from './ElasticHeadline.jsx'
src/components/ElasticHeadline/ElasticHeadline.jsx
'use client'
import { useEffect, useMemo, useRef } from 'react'
import './ElasticHeadline.css'
/* =========================================================
ElasticHeadline
A headline strung on an elastic. Grab a letter and drag —
the whole line deforms behind your hand, and letting go
lets it ring out through its own modes before settling.
The letters are nodes on a one-dimensional mass-spring
chain, pinned one letter-pitch beyond each end, so every
glyph you can see is one you can pull. A headline that
wraps is several strings, one per row, each pinned past
its own ends: a chain run through the line break would
have a pull at the end of one row tug the far start of the
next. Each node feels:
· its neighbours k(yᵢ₋₁ − 2yᵢ + yᵢ₊₁)
· the elastic's friction η(ẏᵢ₋₁ − 2ẏᵢ + ẏᵢ₊₁)
· a pull back to its slot −kₐ·yᵢ
· air −c·ẏᵢ
The first term is the discrete Laplacian, which is what
makes this a wave rather than a row of independent springs.
Displace one node and the disturbance travels outward to
its neighbours and reflects off the pinned ends — that
travelling-and-reflecting is the whole reason it reads as
one object rather than as letters that each happen to
wobble. A row of independent springs gives you a Mexican
wave; a coupled chain gives you a guitar string.
The second term is why it reads as rubber and not wire.
Air damps every mode at the same rate, so with air alone
the short, fast ripples a release throws off chatter for as
long as the slow sway of the whole line does, and the rest
looks like fizz. Friction inside the elastic acts on how
fast neighbours move apart, so it barely touches the sway
and kills the ripples — the way a real band goes quiet.
The hand. Grabbing takes hold of the nearest letter where
it is, mid-swing or not — never the space between words,
which has nothing to lift in answer to the press — and it
then moves by the pointer's travel rather than jumping to
the pointer; a grab that teleports the letter reads as a
glitch. That travel is measured in the headline's own
pixels, so inside a scaled container the letter still stays
under the hand. The glyph in the hand lifts a little toward
it, quickly on the press and more slowly on the release —
the only motion here that is not the chain's. And the hand
is trusted only while it is really there: a cancelled
gesture, a window that loses focus, or a mouse whose button
turns out to be up all let go, so a letter is never left
stuck to a cursor that has stopped holding it.
The chain is linear, which is only true of a string at
small slopes — pulled further, a real elastic stiffens as
it stretches — so the hand's travel goes through
y = s·asinh(u/s): one-to-one for small pulls,
only logarithmic past `stretch`, with no wall to hit. On
release the letter keeps the pointer's velocity, fitted
over its last 70 ms rather than taken from one event, so a
flick throws the line and a still hand simply lets go.
The glyphs are solid. Each one stands on the line and
leans with the slope under it, pivoting on its baseline, so
where the line folds into a valley — the hand's V, a
ripple's trough — neighbouring tops lean toward each other.
Left alone they pass straight through one another, which
reads as a rendering bug, not as crowding. So every pair of
neighbours is a contact: their ink extents come from the
font, and when the facing edges would close by more than
the gap they had at rest, both leans are relaxed, weighted
by lever arm, until they only just touch. Peaks, where the
tops fan apart, are never touched. This shapes only how a
glyph is drawn; the chain beneath it is left alone.
Reduced motion keeps the line pullable but takes away its
inertia. A held letter bends the line into the chain's
static shape — the same forces with the mass removed, so
yᵢ falls off as sinh toward the pins and nothing travels or
rings — and letting go leaves it flat at once. Everything
that moves, moves only because the hand did.
Nothing is reset under the reader. New text takes up the
swing the old text was in, letter for letter, and a resize
that re-wraps the line re-pins its rows around the motion
rather than stopping it.
Integration is semi-implicit Euler, each frame's real
elapsed time cut into equal substeps no longer than 1/120 s
— and no longer than 1/ω of the fastest mode the props
allow, so a stiff, light chain stays stable instead of
exploding. Both friction terms are solved implicitly for
the same reason. Explicit Euler on a stiff chain gains
energy every step, and stepping by the raw frame time would
make the same drag ring differently at 60 and 120 Hz.
========================================================= */
const MAX_STEP = 1 / 120 // longest substep the chain is ever advanced by, s
const MAX_SUBSTEPS = 64 // per frame; only degenerate props ever get near it
const MAX_DT = 1 / 30 // a tab coming back from the background resumes, not lurches
const FLICK = 70 // ms of pointer history the release velocity is fitted over
const LEAN = 0.4 // em of run a glyph's tilt is measured against
const PRESS = 0.06 // how far a held glyph lifts toward the hand, as scale
const PRESS_IN = 0.035 // s, time constant of that lift when a letter is taken
const PRESS_OUT = 0.08 // s, and of its fall when it is let go
const REST = 0.15 // px; below this the line is flat, so it is made flat
const CONTACT_PASSES = 48 // most sweeps of the contact solve per frame, alternating direction
const fin = (x, d) => (Number.isFinite(x) ? x : d)
/* The hand's soft limit: one-to-one near rest, logarithmic past s. */
const soft = (u, s) => (s === Infinity ? u : s * Math.asinh(u / s))
const unsoft = (y, s) => (s === Infinity ? y : s * Math.sinh(Math.max(-30, Math.min(30, y / s))))
const softSlope = (u, s) => (s === Infinity ? 1 : 1 / Math.hypot(1, u / s))
export default function ElasticHeadline({
text = 'Interfaces that move',
/* --- the string --- */
stiffness = 900, // neighbour coupling
anchor = 120, // pull back to the resting slot
damping = 5.2, // air: damps every mode alike
viscosity = 3, // the elastic's own friction: damps ripples, spares the sway
mass = 1,
/* --- what a drag does --- */
grab = 1, // how much of the pointer's travel a held letter takes
stretch = 1.2, // em a held letter follows one-to-one before the elastic resists
reach = 2, // how far a nudge spreads on hover, in letters
nudge = 0, // vertical push from hovering, px (0 = drag only)
lift = 0.35, // how much vertical displacement tilts a glyph
className = '',
style,
...rest
}) {
const rootRef = useRef(null)
// Runs of letters, each an array of glyphs, and single whitespace characters
// between them. Every glyph and every space is one node of the chain.
// Anything that is not a string is read as one, so a null or a number
// handed in by mistake draws what it can instead of throwing.
const label = typeof text === 'string' ? text : text == null ? '' : String(text)
const words = useMemo(() => {
const out = []
let word = null
for (const ch of Array.from(label)) {
if (/\s/.test(ch)) { word = null; out.push(' ') }
else if (word) word.push(ch)
else { word = [ch]; out.push(word) }
}
return out
}, [label])
// The swing in progress when the text changes, so the new letters take it up
// rather than the line snapping flat under the reader.
const carry = useRef(null)
const live = useRef({})
// Synced after every render and before the effect below, so the loop reads
// current props without being torn down and restarted.
useEffect(() => {
live.current = {
stiffness, anchor, damping, viscosity, mass, grab, stretch, reach, nudge, lift,
}
})
useEffect(() => {
const root = rootRef.current
if (!root) return
const cells = Array.from(root.querySelectorAll('.elastic-headline__char'))
const n = cells.length
if (!n) return
// Spaces are nodes too — the elastic runs through the gap — but they have
// no glyph to draw, lift or take hold of.
const solid = cells.map((el) => !el.classList.contains('is-space'))
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
let still = mq.matches
// Vertical displacement and velocity, one per letter, down is positive.
const y = new Float64Array(n)
const v = new Float64Array(n)
const vNext = new Float64Array(n) // Jacobi: every node sees the same old state
const press = new Float64Array(n) // 0..1, how far each glyph is lifted
const lean = new Float64Array(n) // rad, each glyph's tilt as drawn
const drawn = new Array(n).fill('')
// A text change mid-swing hands the new letters the old ones' motion.
let carried = false
const was = carry.current
if (was) {
for (let i = 0; i < Math.min(n, was.y.length); i++) {
y[i] = was.y[i]
v[i] = was.v[i]
if (y[i] || v[i]) carried = true
}
}
// Resting layout, in the root's own box and free of the transforms below.
const cx = new Float64Array(n)
const cy = new Float64Array(n)
// Each wrapped row is its own string, pinned one pitch past its own ends:
// link[i] says node i is tied to node i − 1 rather than to a pin.
const link = new Uint8Array(n)
let longest = n // nodes on the longest row
let em = 16
let pitch = 16 // mean letter advance, px
let rowH = 16
// Ink, for the contacts: how far each glyph's ink reaches above (up) and
// above-or-below (dn) its pivot, and out past the pivot on each side; and
// for each pair of neighbouring inked glyphs on a row, the gap at rest.
const up = new Float64Array(n)
const dn = new Float64Array(n)
const outL = new Float64Array(n)
const outR = new Float64Array(n)
let pairA = []
let pairB = []
let pairGap = []
let pen = null
try { pen = document.createElement('canvas').getContext('2d') } catch { /* no canvas: no contacts */ }
let held = -1 // index of the letter under a held pointer
let pid = null // and which pointer holds it
let zoom = 1 // screen px per layout px, if an ancestor is scaled
let py0 = 0 // pointer y when it took hold, layout px
let py = 0 // pointer y now, layout px
let u0 = 0 // the letter's unsoftened displacement at the grab
let give = Infinity // stretch in px for this hold
let target = 0 // where the hand put the held letter last frame
let trail = [] // [timeStamp, clientY] of the recent drag
let hoverX = null
let raf = 0
let last = 0
let lastDt = 1 / 60 // the display's frame time, as last seen
let onScreen = true
const measure = () => {
const cs = getComputedStyle(root)
em = parseFloat(cs.fontSize) || 16
// offset* ignores transforms. The root is position: relative so they are
// measured from it; if a consumer overrides that, subtract its own offset.
const own = cells[0].offsetParent === root
const ox = own ? 0 : root.offsetLeft
const oy = own ? 0 : root.offsetTop
let w = 0
let hSum = 0
for (let i = 0; i < n; i++) {
const el = cells[i]
cx[i] = el.offsetLeft - ox + el.offsetWidth / 2
cy[i] = el.offsetTop - oy + el.offsetHeight / 2
w += el.offsetWidth
hSum += el.offsetHeight
}
pitch = Math.max(1, w / n)
rowH = Math.max(1, hSum / n)
// A node more than half a row below the one before it starts a new row.
longest = 0
for (let i = 0, run = 0; i < n; i++) {
link[i] = i > 0 && Math.abs(cy[i] - cy[i - 1]) < rowH / 2 ? 1 : 0
run = link[i] ? run + 1 : 1
if (run > longest) longest = run
}
pairA = []
pairB = []
pairGap = []
const c0 = cells[solid.indexOf(true)]
if (!pen || !c0) return
// The pivot is the CSS transform-origin, read back rather than assumed,
// and the baseline sits half the leading below the top of each cell.
const [oX, oY] = getComputedStyle(c0).transformOrigin.split(' ').map(parseFloat)
const fx = c0.offsetWidth ? fin(oX, c0.offsetWidth / 2) / c0.offsetWidth : 0.5
const fy = c0.offsetHeight ? fin(oY, c0.offsetHeight * 0.9) / c0.offsetHeight : 0.9
pen.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
const probe = pen.measureText('x')
const fa = fin(probe.fontBoundingBoxAscent, 0.8 * em)
const fd = fin(probe.fontBoundingBoxDescent, 0.2 * em)
let prev = -1
let prevRight = 0
for (let i = 0; i < n; i++) {
if (!link[i]) prev = -1 // a new row: nothing to its left
up[i] = dn[i] = 0
if (!solid[i]) continue
const el = cells[i]
const h = el.offsetHeight
const left = el.offsetLeft - ox
const pivotX = left + fx * el.offsetWidth
const below = fy * h - ((h - fa - fd) / 2 + fa) // pivot below the baseline, px
const m = pen.measureText(el.textContent)
const inkL = left - fin(m.actualBoundingBoxLeft, 0)
const inkR = left + fin(m.actualBoundingBoxRight, 0)
if (!(inkR > inkL)) continue // no ink to touch
up[i] = below + fin(m.actualBoundingBoxAscent, 0.7 * em)
dn[i] = below - fin(m.actualBoundingBoxDescent, 0)
outL[i] = Math.max(0, pivotX - inkL)
outR[i] = Math.max(0, inkR - pivotX)
if (prev >= 0) {
pairA.push(prev)
pairB.push(i)
pairGap.push(inkL - prevRight)
}
prev = i
prevRight = inkR
}
}
/* The pointer in the headline's own layout px. The letters are laid out
and moved in those; the pointer arrives in screen px, and the two part
ways when an ancestor is scaled — a zoomed preview, a card that grows on
hover. The root's drawn width over its laid-out width is that scale, and
dividing the hand's travel by it keeps the letter under the hand rather
than lagging it or running ahead. offsetWidth is whole pixels, so a
ratio within a couple of percent of 1 is read as 1. */
const local = (e) => {
const b = root.getBoundingClientRect()
const z = root.offsetWidth ? b.width / root.offsetWidth : 1
zoom = Number.isFinite(z) && z > 0.01 && Math.abs(z - 1) > 0.02 ? z : 1
return [(e.clientX - b.left) / zoom - root.clientLeft, (e.clientY - b.top) / zoom - root.clientTop]
}
// The letter drawn nearest the pointer — where it is now, not its slot, so
// a grab mid-swing takes the glyph under the finger. Never a space: a grab
// between words takes the nearer letter, so there is always a glyph to lift
// in answer to the press.
const anySolid = solid.includes(true)
const pick = (x, yy) => {
let best = 0
let bd = Infinity
for (let i = 0; i < n; i++) {
if (anySolid && !solid[i]) continue
const dx = cx[i] - x
const dy = cy[i] + y[i] - yy
const d = dx * dx + dy * dy
if (d < bd) { bd = d; best = i }
}
return best
}
/* Neighbours as contacts. Heights are measured down from the resting
pivot line, so a glyph's pivot sits at y[i]; a point ℓ above it moves
sideways by ℓ·sin(lean) when the glyph tilts. Over the band of height
both glyphs have ink in, the facing edges close by a linear function
of height, so the worse end of the band is where they meet. Past the
room they had at rest, the excess is taken off both leans in
proportion to their lever arms — the least tilt that makes them just
touch — and alternating sweeps, until nothing overlaps by more than a
twentieth of a pixel, let a crowded valley settle. The held glyph is
in the hand and does not yield, so its neighbours do. */
const contact = () => {
const count = pairA.length
for (let pass = 0, worst = Infinity; pass < CONTACT_PASSES && worst > 0.05; pass++) {
worst = 0
for (let q = 0; q < count; q++) {
const p = pass & 1 ? count - 1 - q : q
const a = pairA[p]
const b = pairB[p]
// The grip is as firm as the lift, so a letter let go rejoins the
// crowd over the same quarter-second its lift falls away.
const wa = 1 - press[a]
const wb = 1 - press[b]
const sa = 1 + PRESS * press[a]
const sb = 1 + PRESS * press[b]
const top = Math.max(y[a] - up[a] * sa, y[b] - up[b] * sb)
const bot = Math.min(y[a] - dn[a] * sa, y[b] - dn[b] * sb)
if (!(top < bot)) continue // one has swung clear of the other
const sinA = Math.sin(lean[a])
const sinB = Math.sin(lean[b])
let la = y[a] - top
let lb = y[b] - top
let close = la * sinA - lb * sinB
const closeBot = (y[a] - bot) * sinA - (y[b] - bot) * sinB
if (closeBot > close) { la = y[a] - bot; lb = y[b] - bot; close = closeBot }
// A lifted glyph is a little wider, so it has a little less room.
const room = Math.max(0, pairGap[p] - PRESS * (press[a] * outR[a] + press[b] * outL[b]))
const over = close - room
if (over <= 0) continue
if (over > worst) worst = over
const d = over / (wa * la * la + wb * lb * lb || 1)
lean[a] -= wa * d * la
lean[b] += wb * d * lb
}
}
}
const paint = () => {
const lk = fin(live.current.lift, 0.35)
const run = LEAN * em
// Tilt from the local slope, so a letter leans into the curve it sits on.
// Measured in em, so a small and a large headline lean alike.
for (let i = 0; i < n; i++) {
const l = link[i] ? y[i - 1] : 0
const r = i < n - 1 && link[i + 1] ? y[i + 1] : 0
lean[i] = Math.atan2((r - l) * lk, run)
}
contact()
for (let i = 0; i < n; i++) {
if (!solid[i]) continue // an inline space takes no transform
const s = press[i]
let t = ''
if (y[i] || lean[i] || s) {
t = `translateY(${y[i].toFixed(2)}px) rotate(${lean[i].toFixed(4)}rad)`
if (s) t += ` scale(${(1 + PRESS * s).toFixed(4)})`
}
if (t !== drawn[i]) { cells[i].style.transform = t; drawn[i] = t }
}
}
/* The chain's static shape with node h held at Y: every other node on its
row obeys k(yᵢ₋₁ − 2yᵢ + yᵢ₊₁) = kₐ·yᵢ, whose solutions are sinh(μ·j)
with cosh μ = 1 + kₐ/2k, j counted from the pin one pitch past each end
of the row. The other rows are other strings, and lie flat. */
const bend = (h, Y, k, ka) => {
const mu = k > 0 ? Math.acosh(1 + ka / (2 * k)) : Infinity
// sinh(μa)/sinh(μb) for 0 < a ≤ b, in a form a long line cannot overflow.
const fall = (a, b) => {
if (a === b) return 1
if (mu === Infinity) return 0
if (mu < 1e-9) return a / b // no anchor: straight lines
return Math.exp(mu * (a - b)) * (Math.expm1(-2 * mu * a) / Math.expm1(-2 * mu * b))
}
let a = h
while (link[a]) a--
let b = h
while (b < n - 1 && link[b + 1]) b++
y.fill(0)
for (let i = a; i <= b; i++) {
y[i] = i < h ? Y * fall(i - a + 1, h - a + 1) : i > h ? Y * fall(b - i + 1, b - h + 1) : Y
}
}
const step = (h, k, ka, c, eta, m) => {
for (let i = 0; i < n; i++) {
if (i === held) { vNext[i] = v[i]; continue }
const tl = link[i]
const tr = i < n - 1 && link[i + 1]
const l = tl ? y[i - 1] : 0
const r = tr ? y[i + 1] : 0
const vl = tl ? v[i - 1] : 0
const vr = tr ? v[i + 1] : 0
// Discrete Laplacian — this is the term that makes it a wave. The
// friction's own-velocity part and the air go on the implicit side.
const f = k * (l - 2 * y[i] + r) - ka * y[i] + eta * (vl + vr)
vNext[i] = (v[i] + (h * f) / m) / (1 + (h * (c + 2 * eta)) / m)
}
for (let i = 0; i < n; i++) {
if (i === held) continue
v[i] = vNext[i]
y[i] += v[i] * h
}
}
const frame = (now) => {
raf = 0
const p = live.current
const m = Math.max(0.05, fin(p.mass, 1))
let k = Math.max(0, fin(p.stiffness, 900))
let ka = Math.max(0, fin(p.anchor, 120))
const c = Math.max(0, fin(p.damping, 5.2))
const eta = Math.max(0, fin(p.viscosity, 3))
if (still) {
// No inertia: the line takes its static shape around the hand, or lies
// flat without it, and nothing is left running.
if (held >= 0) {
target = soft(u0 + fin(p.grab, 1) * (py - py0), give)
bend(held, target, k, ka)
} else y.fill(0)
v.fill(0)
for (let i = 0; i < n; i++) press[i] = i === held ? 1 : 0
paint()
last = 0
return
}
// Real elapsed time; the first frame after a rest borrows the last one's,
// and so does a frame whose clock has not advanced, which has no speed.
const dt = last && now > last ? Math.min(MAX_DT, (now - last) / 1000) : lastDt
if (last && dt < MAX_DT) lastDt = dt
last = now
// Equal slices of real time, none longer than the fastest mode can take.
const wMax = Math.sqrt((4 * k + ka) / m)
const steps = Math.min(MAX_SUBSTEPS, Math.max(1, Math.ceil(dt / Math.min(MAX_STEP, 1 / (wMax || 1)))))
const h = dt / steps
// Only a degenerate chain lands here: soften it to what the step can hold.
if (wMax * h > 1.5) {
const s = (1.5 / (wMax * h)) ** 2
k *= s
ka *= s
}
// The held letter is kinematic: it goes where the hand goes, and the
// chain has to deal with it. Spreading the frame's hand motion across the
// substeps keeps its neighbours from being kicked once per frame.
const from = target
if (held >= 0) target = soft(u0 + fin(p.grab, 1) * (py - py0), give)
const to = held >= 0 ? target : from
for (let s = 0; s < steps; s++) {
if (held >= 0) {
y[held] = from + ((to - from) * (s + 1)) / steps
v[held] = (to - from) / dt
}
step(h, k, ka, c, eta, m)
}
// The lift eases on real time: quick to answer a grab, slower to let go.
const kIn = 1 - Math.exp(-dt / PRESS_IN)
const kOut = 1 - Math.exp(-dt / PRESS_OUT)
let lifting = false
for (let i = 0; i < n; i++) {
const goal = i === held ? 1 : 0
press[i] += (goal - press[i]) * (goal ? kIn : kOut)
if (Math.abs(goal - press[i]) < 0.002) press[i] = goal
else lifting = true
}
let sum = 0
for (let i = 0; i < n; i++) sum += y[i] + v[i]
if (!Number.isFinite(sum)) { y.fill(0); v.fill(0) }
paint()
/* Settled when no letter could still move by a visible fraction of a pixel:
each node's amplitude, bounded using the slowest mode, is under REST. A
held line only has to stop moving, since the hand is keeping it bent —
and so does one with no stiffness and no anchor, which has nothing to
bring it back and is left where it stopped rather than pulled flat. */
const w1 = Math.max(1, Math.sqrt((ka + 4 * k * Math.sin(Math.PI / (2 * (longest + 1))) ** 2) / m))
const parked = held >= 0 || !(k + ka > 0)
let quiet = !lifting && to === from
for (let i = 0; quiet && i < n; i++) {
if (i === held) continue
const a = v[i] / w1
if (parked ? Math.abs(a) > 0.02 : y[i] * y[i] + a * a > REST * REST) quiet = false
}
if (quiet) {
if (!parked) {
y.fill(0)
v.fill(0)
paint()
}
last = 0
return
}
raf = requestAnimationFrame(frame)
}
const kick = () => {
if (raf || !onScreen) return
last = 0
raf = requestAnimationFrame(frame)
}
/* Release velocity: a least-squares fit of the pointer's y over the last
FLICK ms. One event's delta is noise; a hand that stopped before letting
go has no samples in the window and throws nothing. */
const flick = (tEnd) => {
let i = trail.length
while (i > 0 && trail[i - 1][0] >= tEnd - FLICK) i--
const pts = trail.slice(i)
if (pts.length < 2 || pts[pts.length - 1][0] - pts[0][0] < 8) return 0
let mt = 0
let my = 0
for (const [t, yy] of pts) { mt += t; my += yy }
mt /= pts.length
my /= pts.length
let num = 0
let den = 0
for (const [t, yy] of pts) { num += (t - mt) * (yy - my); den += (t - mt) ** 2 }
return den ? (num / den) * 1000 : 0
}
const letGo = (tEnd, fling) => {
if (held < 0) return
const i = held
let vel = 0
if (fling) {
const g = fin(live.current.grab, 1)
vel = flick(tEnd) * g * softSlope(u0 + g * (py - py0), give)
const cap = 80 * em
vel = Math.max(-cap, Math.min(cap, vel))
}
held = -1
// It keeps the place the hand left it and the hand's speed.
v[i] = vel
target = y[i]
trail = []
try { if (root.hasPointerCapture?.(pid)) root.releasePointerCapture(pid) } catch { /* already gone */ }
pid = null
root.classList.remove('is-held')
kick()
}
const onDown = (e) => {
if (held >= 0 || e.button !== 0) return
measure()
const [x, yy] = local(e)
held = pick(x, yy)
pid = e.pointerId
py0 = py = e.clientY / zoom
const s = live.current.stretch
give = s === Infinity ? Infinity : Math.max(0.01, fin(s, 1.2)) * em
// Take hold of the letter where it is: the pointer's travel is added to
// the displacement it already had, so nothing jumps.
u0 = unsoft(y[held], give)
target = y[held]
trail = [[e.timeStamp, py]]
try { root.setPointerCapture(e.pointerId) } catch { /* synthetic pointer */ }
root.classList.add('is-held')
kick()
}
const onMove = (e) => {
if (held >= 0) {
if (e.pointerId !== pid) return
// A mouse with its button up is holding nothing, whatever we were last
// told: a release that landed in another window must not leave the
// letter stuck to the cursor.
if (e.pointerType === 'mouse' && !(e.buttons & 1)) { letGo(e.timeStamp, false); return }
const evs = e.getCoalescedEvents?.()
if (evs?.length) for (const ev of evs) trail.push([ev.timeStamp, ev.clientY / zoom])
else trail.push([e.timeStamp, e.clientY / zoom])
while (trail.length > 2 && trail[0][0] < e.timeStamp - 4 * FLICK) trail.shift()
py = e.clientY / zoom
kick()
return
}
// Hovering only does anything if `nudge` is on, only for a pointer that
// can hover, and never under reduced motion, where a pluck nobody held
// would ring on its own. The push is paid per letter crossed, not per
// event, so a 1000 Hz mouse and a 60 Hz trackpad pluck the line alike,
// and it is scaled by the chain's own frequency so a brisk pass lifts
// about `nudge` px.
const { nudge: nu0, reach: r0, stiffness: k0, anchor: a0, mass: m0 } = live.current
const nu = Math.max(0, fin(nu0, 0))
if (!nu || still || e.pointerType === 'touch') { hoverX = null; return }
const [x, yy] = local(e)
const dx = hoverX === null ? 0 : Math.abs(x - hoverX)
hoverX = x
if (!dx) return
const rr = Math.max(0, fin(r0, 2))
const span = (rr + 1) * pitch
const w = Math.sqrt(
(Math.max(0, fin(a0, 120)) + Math.max(0, fin(k0, 900))) / Math.max(0.05, fin(m0, 1)),
)
const push = (nu * w * (dx / pitch)) / (rr + 1)
for (let i = 0; i < n; i++) {
if (i === held || Math.abs(cy[i] - yy) > rowH) continue
const f = 1 - Math.abs(cx[i] - x) / span
if (f > 0) v[i] += push * f
}
kick()
}
const onLeave = () => { hoverX = null }
const onUp = (e) => {
if (e.pointerId !== pid) return
trail.push([e.timeStamp, e.clientY / zoom])
letGo(e.timeStamp, true)
}
// A cancel's coordinates are often junk, and the browser took the gesture
// back; the line just lets go where it is. So does a window that loses
// focus mid-drag, since it may never hear the button come up.
const onCancel = (e) => { if (e.pointerId === pid) letGo(e.timeStamp, false) }
const onLost = (e) => { if (e.pointerId === pid) letGo(e.timeStamp, true) }
const onBlur = () => letGo(0, false)
// Switching either way mid-gesture keeps the hold: the next frame simply
// draws the line the new way, starting from where it is.
const onMotionPref = () => {
still = mq.matches
if (still) v.fill(0)
kick()
}
const io = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting
if (onScreen) kick()
else if (raf) { cancelAnimationFrame(raf); raf = 0 }
})
io.observe(root)
const ro = new ResizeObserver(() => { measure(); paint() })
ro.observe(root)
measure()
// Taking over a swing: draw it now, in this commit, so the new glyphs never
// show a flat frame, and let the loop carry it on.
if (carried) { paint(); kick() }
root.addEventListener('pointerdown', onDown)
root.addEventListener('pointermove', onMove, { passive: true })
root.addEventListener('pointerleave', onLeave)
root.addEventListener('lostpointercapture', onLost)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
window.addEventListener('blur', onBlur)
mq.addEventListener?.('change', onMotionPref)
let dead = false
document.fonts?.ready?.then(() => { if (!dead) { measure(); paint() } })
return () => {
dead = true
io.disconnect()
ro.disconnect()
root.removeEventListener('pointerdown', onDown)
root.removeEventListener('pointermove', onMove)
root.removeEventListener('pointerleave', onLeave)
root.removeEventListener('lostpointercapture', onLost)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('blur', onBlur)
mq.removeEventListener?.('change', onMotionPref)
if (raf) cancelAnimationFrame(raf)
if (held >= 0) {
try { if (root.hasPointerCapture?.(pid)) root.releasePointerCapture(pid) } catch { /* gone */ }
root.classList.remove('is-held')
}
// React keeps these spans when the text changes, so a line torn down
// mid-swing must not leave its last pose on the glyphs that replace it;
// the motion itself is handed on to whichever line comes next.
for (const el of cells) el.style.transform = ''
carry.current = still ? null : { y: Array.from(y), v: Array.from(v) }
}
}, [words])
return (
<span
ref={rootRef}
className={`elastic-headline ${className}`.trim()}
style={style}
/* Every glyph is hidden below, so this is the only accessible name —
and a bare span's generic role cannot carry one. */
role="img"
aria-label={label}
{...rest}
>
{/* Letters are grouped into unbreakable words, and a space is a real,
breakable space: a line of inline-blocks alone would wrap mid-word
and start its next row with an indent. */}
{words.map((w, i) =>
typeof w === 'string' ? (
<span key={i} className="elastic-headline__char is-space" aria-hidden="true">{w}</span>
) : (
<span key={i} className="elastic-headline__word" aria-hidden="true">
{w.map((ch, j) => (
<span key={j} className="elastic-headline__char">{ch}</span>
))}
</span>
),
)}
</span>
)
}
src/components/ElasticHeadline/ElasticHeadline.css
/* ElasticHeadline — JS writes one transform per letter and nothing else. The
letters are inline-block so they can be transformed while the line still
wraps and justifies like ordinary text. */
.elastic-headline {
/* Positioned so each letter's offsetLeft/Top — its resting slot, untouched by
the transforms — is measured from the headline itself. */
position: relative;
display: inline-block;
cursor: grab;
-webkit-user-select: none;
user-select: none;
/* The drag is vertical, which is also the page's scroll axis, so a finger
that lands on the letters belongs to the letters. Two fingers still
belong to the page: a pinch that starts here zooms, and the browser's
pointercancel lets go of the letter the first finger took. */
touch-action: pinch-zoom;
-webkit-tap-highlight-color: transparent;
}
.elastic-headline.is-held { cursor: grabbing; }
.elastic-headline__char {
display: inline-block;
/* Bottom-centre, so a tilted glyph pivots on its baseline rather than
swinging about its middle and drifting off the line — and a held glyph
lifts up off the line rather than swelling in place. */
transform-origin: 50% 90%;
will-change: transform;
}
/* A word is one unbreakable box. Its letters are inline-blocks, and a row of
inline-blocks may wrap between any two of them — mid-word. */
.elastic-headline__word {
display: inline-block;
white-space: nowrap;
}
/* A space has no glyph to transform, so it stays an ordinary inline space: it
holds its width while its neighbours move, it is where a long headline
wraps, and at a row's end it hangs rather than indenting the next row. */
.elastic-headline__char.is-space {
display: inline;
white-space: pre-wrap;
will-change: auto;
}
/* Reduced motion still lets a letter be pulled — the line only ever moves with
the hand, never on its own — so it stays grabbable; the glyphs just need not
be kept on their own layers between drags. */
@media (prefers-reduced-motion: reduce) {
.elastic-headline__char { will-change: auto; }
}
src/components/ElasticHeadline/ElasticHeadline.d.ts
import type { ComponentPropsWithoutRef } from 'react'
export interface ElasticHeadlineProps extends ComponentPropsWithoutRef<'span'> {
/** Rendered per glyph, and used as the element's accessible name. */
text?: string
/** Neighbour coupling. This is the term that makes the line a wave. */
stiffness?: number
/** Pull back to the resting slot. Higher settles flatter, faster. */
anchor?: number
/** Air resistance: damps every mode of the line alike, the slow sway included. */
damping?: number
/** The elastic's own friction, acting on how fast neighbours move apart. It quiets
* the short ripples a release throws off and barely touches the sway; 0 leaves
* only `damping`. */
viscosity?: number
/** Per-letter mass. Heavier rings lower and longer. */
mass?: number
/** How much of the pointer's travel a held letter takes, 0..1. */
grab?: number
/** How far, in em, a held letter follows the pointer one-to-one before the elastic
* resists; past it the letter gains only logarithmically. `Infinity` follows the
* pointer exactly. */
stretch?: number
/** How far a hover nudge spreads, in letters. */
reach?: number
/** Vertical push from hovering, in px. 0 makes the component drag-only. */
nudge?: number
/** How much vertical displacement tilts a glyph. */
lift?: number
}
declare const ElasticHeadline: (props: ElasticHeadlineProps) => JSX.Element
export default ElasticHeadline