poking

Spring physics, from scratch

A damped spring, integrated by hand in six lines. Click to move the target, drag the sliders, watch ζ.

Sandbox

Under the hood

A spring pulls toward its target with force -k · (x - target) and loses energy to damping at -c · v. That’s the whole model. Every frame I add those up, nudge the velocity, then nudge the position with the new velocity (semi-implicit Euler). Swap the order and the spring slowly gains energy and blows up.

The physics runs on a fixed 1/240 s step, decoupled from the frame rate. A 60 Hz and a 120 Hz screen take the same steps, so the motion looks the same on both. Big variable steps are where hand-rolled springs go unstable.

One number tells you how it’ll feel: the damping ratio ζ = c / (2√k). Under 1 it overshoots and wobbles, at 1 it lands as fast as possible without overshooting, over 1 it creeps in. Most UI springs want something around 0.7 to 0.9.

Fork

spring.js
// Damped spring, integrated by hand. Click or tap the canvas to move the target.
// mount(el) builds the whole thing inside el and returns a cleanup function.

const STEP = 1 / 240; // fixed physics step, independent of frame rate

export function mount(el) {
  el.innerHTML = `
    <canvas style="display:block;width:100%;height:240px;cursor:crosshair;touch-action:none"></canvas>
    <div class="controls">
      <label>stiffness k <input type="range" name="k" min="20" max="600" value="170"><output></output></label>
      <label>damping c <input type="range" name="c" min="0" max="60" value="12"><output></output></label>
      <span class="readout"></span>
    </div>`;

  const canvas = el.querySelector('canvas');
  const ctx = canvas.getContext('2d');
  const inputs = { k: el.querySelector('[name=k]'), c: el.querySelector('[name=c]') };
  const readout = el.querySelector('.readout');

  let x = 0.2, v = 0, target = 0.8; // positions are 0..1 across the canvas
  const trace = [];
  let colors = readColors(el);

  const update = () => {
    for (const input of Object.values(inputs)) input.nextElementSibling.value = input.value;
    const k = +inputs.k.value, c = +inputs.c.value;
    const zeta = c / (2 * Math.sqrt(k)); // damping ratio, mass = 1
    const kind = zeta < 1 ? 'underdamped' : zeta === 1 ? 'critical' : 'overdamped';
    readout.textContent = `ζ ${zeta.toFixed(2)} · ${kind}`;
  };
  for (const input of Object.values(inputs)) input.addEventListener('input', update);
  update();

  const onPointer = (e) => {
    const r = canvas.getBoundingClientRect();
    target = Math.min(0.95, Math.max(0.05, (e.clientX - r.left) / r.width));
  };
  canvas.addEventListener('pointerdown', onPointer);
  const onTheme = () => (colors = readColors(el));
  window.addEventListener('themechange', onTheme);

  let last = performance.now(), acc = 0, raf = 0;
  const frame = (now) => {
    acc += Math.min(0.1, (now - last) / 1000);
    last = now;
    const k = +inputs.k.value, c = +inputs.c.value;
    while (acc >= STEP) {
      // Semi-implicit Euler: update velocity first, then position with the new velocity.
      v += (-k * (x - target) - c * v) * STEP;
      x += v * STEP;
      acc -= STEP;
    }
    trace.push(x);
    if (trace.length > 240) trace.shift();
    draw(ctx, canvas, x, target, trace, colors);
    raf = requestAnimationFrame(frame);
  };
  raf = requestAnimationFrame(frame);

  return () => {
    cancelAnimationFrame(raf);
    window.removeEventListener('themechange', onTheme);
  };
}

function draw(ctx, canvas, x, target, trace, c) {
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.clientWidth, h = canvas.clientHeight;
  if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
    canvas.width = w * dpr;
    canvas.height = h * dpr;
  }
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);

  // Trace: the last 240 frames of position, newest at the bottom.
  ctx.strokeStyle = c.faint;
  ctx.lineWidth = 1;
  ctx.beginPath();
  trace.forEach((p, i) => {
    const y = h - 16 - (trace.length - 1 - i) * ((h - 96) / 240);
    i ? ctx.lineTo(p * w, y) : ctx.moveTo(p * w, y);
  });
  ctx.stroke();

  // Target: a 1px line.
  ctx.strokeStyle = c.ink;
  ctx.beginPath();
  ctx.moveTo(Math.round(target * w) + 0.5, 0);
  ctx.lineTo(Math.round(target * w) + 0.5, h);
  ctx.stroke();

  // The mass.
  ctx.fillStyle = c.accent;
  ctx.fillRect(x * w - 16, 24, 32, 32);
}

function readColors(el) {
  const s = getComputedStyle(el);
  const get = (name, fallback) => s.getPropertyValue(name).trim() || fallback;
  return {
    accent: get('--accent', '#00ff66'),
    ink: get('--ink-muted', '#a1a1a1'),
    faint: get('--line-strong', '#3d3d3d'),
  };
}