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'),
};
}