Always cooking something
โ† All posts
May 3, 2026ยท7 min read

Spring Physics for Text Animation

CanvasAnimationCreative Coding

CSS transitions are fine. They get you from A to B with a curve you can describe as a cubic-bezier. But there is a class of motion that feels different: overshooting its target, oscillating, settling with weight. Cubic-bezier cannot express it. That is where spring physics comes in.

A spring simulation takes three lines of math. You can wire it to any numeric property: position, scale, opacity, rotation, color. Once it is running, the motion it produces feels physical in a way that curves alone never quite achieve, because it is actually simulating inertia and energy loss.

Here is the engine at the core of inkmotion, a library I built for this, stripped down to its smallest working form.


The three-line formula

A spring has a position, a velocity, and a target. Every frame, it does three things: accelerate toward the target, bleed off some energy, and move.

js
function spring(s, target, k = 0.08, d = 0.72) {
  s.vel += (target - s.pos) * k   // pull toward target
  s.vel *= d                       // drain energy (damping)
  s.pos += s.vel                   // move
}

s is a plain object with pos and vel. You create one per thing you want to animate and call this once per animation frame. That is the whole engine.

Two parameters control everything:

  • โ†’k: stiffness. How hard the spring pulls toward the target each frame. Low values (0.03โ€“0.06) feel heavy and dreamy. High values (0.3+) feel snappy and rigid. Think of it as the tension in the spring.
  • โ†’d: damping. How much velocity survives each frame. Close to 1.0 means almost all velocity is preserved, so the spring keeps oscillating. Pull it down toward 0.5 and the motion becomes bouncy, then overdamped if you go too low. Around 0.7โ€“0.8 is the sweet spot for UI.

Drag both sliders and click anywhere to set a new target. Feel the difference between a rigid snap and a lazy float.

Interactive ยท Spring playground
k (stiffness): Soft0.080
d (damping): Balanced0.72
Click to move the target
โ— targetโ— spring
The spring never lands exactly on target. Each frame it closes a fraction of the remaining gap, using the same geometric decay as running lerp. To stop the loop when it is close enough, check the combined rest condition: Math.abs(vel) + Math.abs(target - pos) < 0.05. When that is true, the motion is invisible and you can cancel the animation frame.

The three damping regimes

Damping has three qualitatively distinct zones, and each one produces a completely different character of motion. The chart below simulates the same spring starting at 1.0 and moving toward 0 under three different damping values.

Simulation ยท Damping comparison
  • โ†’Underdamped (d โ‰ˆ 0.5). Bouncy. The spring overshoots zero, swings back past it on the other side, and oscillates until it runs out of energy. Good for playful, expressive motion. The classic "jelly" feel.
  • โ†’Critically damped (d โ‰ˆ 0.72). The spring reaches the target in the minimum time without oscillating. This is the sweet spot for most UI. Feels physical without being distracting.
  • โ†’Overdamped (d โ‰ˆ 0.94). The spring creeps toward the target without ever overshooting. Slow and viscous. Useful for very subtle motions or when you want the animation to feel deliberate rather than snappy.

Extending to 2D

For effects where you need to move something in two dimensions (like a character being pushed away by the cursor, or a floating element following a mouse), you run two independent springs and let them share state:

js
// s = { x, y, vx, vy }
function spring2D(s, tx, ty, k = 0.08, d = 0.72) {
  s.vx += (tx - s.x) * k;  s.vx *= d;  s.x += s.vx
  s.vy += (ty - s.y) * k;  s.vy *= d;  s.y += s.vy
}

Each axis is fully independent. The x spring knows nothing about y. But because they share the same k and d, they settle at the same rate, which makes the resulting motion feel natural rather than mechanical.

Each character has a { x, y, vx, vy } state object. On every frame the spring pulls it back toward origin, while the cursor proximity calculation pushes it outward. The two forces balance and you get smooth, springy repulsion with no additional code.


Wiring it to text: split, stagger, loop

The spring engine only moves numbers. To animate text, you need to split the string into individual DOM elements and give each one its own spring state.

js
// 1. Split text into one <span> per character
function split(el) {
  const raw = el.textContent
  el.textContent = ''
  return raw.split('').map(ch => {
    const span = document.createElement('span')
    span.className = 'char'
    span.textContent = ch
    el.appendChild(span)
    return span
  })
}

// 2. Create spring state for each character
const chars = split(textEl)
const st = chars.map(() => ({ pos: -80, vel: 0 }))

// 3. Run the loop with a stagger delay
let frame = 0
function tick() {
  frame++
  chars.forEach((span, i) => {
    if (frame < i * 3) return  // stagger: delay by 3 frames per char
    spring(st[i], 0, 0.07, 0.68)
    span.style.transform = `translateY(${st[i].pos}px)`
  })
  requestAnimationFrame(tick)
}

The stagger is the key to the wave-like feel. Each character waits i * 3 frames before its spring starts, so the animation ripples left to right. Change that multiplier and the wave moves faster or slower. Set it to zero and all letters move in unison.

The entrance animation here starts every letter at pos: -80 (80px above its natural position) and springs toward 0. It runs the opacity the same way: a second spring starting at 0 pulling toward 1. The letter fades in as it drops.

Interactive ยท Spring entrance animation

Notice the slight bounce at the end of each character. That is the spring overshooting zero for a frame or two before settling. It is not choreographed. It is just physics. The k = 0.07, d = 0.68 combination leaves just enough energy in the system to produce a gentle landing without wild oscillation.


Managing the rAF loop

One detail worth getting right: the loop should stop when all springs have settled. Leaving a requestAnimationFrame running forever wastes CPU and can cause issues when the animation replays. The spring function can return a settled flag:

js
function spring(s, target, k = 0.08, d = 0.72) {
  s.vel += (target - s.pos) * k
  s.vel *= d
  s.pos += s.vel
  return Math.abs(s.vel) + Math.abs(target - s.pos) < 0.05
  //     โ†‘ returns true when motion is imperceptible
}

// In the loop: only continue if any spring is still moving
function tick() {
  let allSettled = true
  chars.forEach((span, i) => {
    const settled = spring(st[i], 0, 0.07, 0.68)
    if (!settled) allSettled = false
    span.style.transform = `translateY(${st[i].pos}px)`
  })
  if (!allSettled) requestAnimationFrame(tick)
}

For interactive effects that need to stay alive (cursor repulsion, continuous hover response), keep the loop running. For entrance animations that play once, stopping on settle keeps things clean.

Always cancel the previous loop before starting a new one. If the animation can be replayed, keep a reference with let raf = requestAnimationFrame(tick) and call cancelAnimationFrame(raf) at the top of the replay function. Otherwise stacked loops compound and the motion accelerates on each replay.

Quick reference

  • โ†’k 0.04โ€“0.06 / d 0.80โ€“0.88. Heavy, floating, dreamlike. Like something underwater.
  • โ†’k 0.07โ€“0.09 / d 0.68โ€“0.74. The sweet spot. Physical and responsive without being bouncy.
  • โ†’k 0.10โ€“0.14 / d 0.58โ€“0.65. Energetic and elastic. Good for playful interactions.
  • โ†’k 0.20โ€“0.40 / d 0.75โ€“0.85. Snappy and decisive. Almost instant, slight settle.
  • โ†’Stagger multiplier 2โ€“4. Fast wave that reads as responsive. Good for short words.
  • โ†’Stagger multiplier 5โ€“8. Slow wave with visible sequencing. Good for long phrases.

newsletter

Stay in the loop

New experiments, articles, and tools โ€” straight to your inbox. No spam, unsubscribe anytime.