JavaScript Physics Simulation: 9 Demos, No Library
A JavaScript physics simulation is arithmetic repeated once per frame: force adds to velocity, velocity adds to position, and then the element is drawn.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Spring follow
- 02 Gravity drop
- 03 Pendulum swing
- 04 Magnet particles
- 05 Paddle bounce
- 06 Cloth ripple
- 07 Flow field
- 08 Inertia box
- 09 Inertia scrub
The order follows how much of the motion your own hand controls. The first three start a law and let it settle on its own — a spring chasing a target (01), gravity meeting a floor (02), an arm trading angle for height (03). The middle three turn the cursor into a source of force — a magnet over iron filings (04), a paddle against a ball (05), a sheet of cloth under a fingertip (06). The last three hand over the mass itself — steering a current (07), shaking a box (08), flicking a row of cards (09). All nine move elements through translate or rotate and nothing else, so one of them can sit beside pointer-following 3D scenes or under an audio visualizer without a second animation system underneath. Drawing a shape rather than moving one is a different job, and it belongs to SVG chart drawing instead.
01Spring follow
Click anywhere on the field and that spot becomes the target — the chip overshoots it, swings back, and sticks, while two sliders set the stiffness and the damping. Every frame adds acceleration to the velocity first and moves the position afterward, which is semi-implicit Euler — the cheapest integrator that stays stable at these step sizes. The preview measured 11.9% cumulative changed area at intensity 106.9, moving across 23 of 23 frame gaps.
function step(now) {
// Semi-implicit Euler: apply acceleration first, then move the position. dt is the frame gap in seconds.
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var k = Number(stiffIn.value) * 60;
var c = Number(dampIn.value) * 1.5;
vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
frame++;
if (frame % 2 === 0) {
trail[2] = trail[1]; trail[1] = trail[0]; trail[0] = { x: pos.x, y: pos.y };
}
paint();
var settled = Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5
&& Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
if (settled) {
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
paint(); running = false; return;
}
requestAnimationFrame(step);
}
02Gravity drop
Four balls of different sizes fall together, rebound off the floor by whatever the restitution slider says, and shove each other apart wherever they overlap. Gravity is a single constant of 1800 px/s², and any bounce that leaves under 40 px/s counts as rest, so the loop can stop instead of buzzing forever. Its intensity of 187.6 is the highest of the nine, spread over 5.9% cumulative area and 22 of 23 gaps.
b.vy += G * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// Floor contact: rebound by the restitution value, and park the ball once the leftover speed is small
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// Ball to ball: push each other apart by the overlap (simple contact — no velocity exchange)
for (var j = i + 1; j < balls.length; j++) {
var o = balls[j];
var dx = o.x - b.x, dy = o.y - b.y, gap = (b.size + o.size) / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
var push = (gap - dist) / 2, nx = dx / dist, ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
03Pendulum swing
Drag the bob aside and let go: the angle turns into stored height, and the swing sustains itself until damping eats it. Angular acceleration is -(g/L)·sin θ, so the arm rotates about a pivot instead of translating, and its transform-origin sits on the top edge. It measured 5.6% cumulative area at intensity 119.3, with 23 of 23 gaps moving and five keyframe tracks in the preview.
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// Angular acceleration = -(g/L)·sin θ minus damping. Angle turning into height (energy) is what a pendulum is.
// RAD is 180/π — angle and avel are kept in degrees, so divide going into sin and multiply the rad/s² back out.
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
angle += avel * dt;
paint();
if (Math.abs(angle) < 0.2 && Math.abs(avel) < 0.6) {
angle = 0; avel = 0; paint(); running = false; return;
}
requestAnimationFrame(step);
}
04Magnet particles
Thirty-two iron filings rest on a jittered 8×4 grid while the cursor plays the magnet, and one button flips it between pulling and pushing. Force is a Coulomb stand-in, a sign divided by r² plus a softening constant, and that constant is what keeps the division from blowing up when the cursor lands directly on a filing. Cumulative changed area came out at 18.8% with intensity 158.9 over 23 of 23 gaps, across 33 preview tracks.
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var lively = now - idleAt < 1600, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var dx = d.x - mag.x, dy = d.y - mag.y;
var r2 = dx * dx + dy * dy;
// Coulomb stand-in: force = sign / (r² + softening) — inverse square, and never a division by zero
var f = (attract ? -5200 : 4200) / (r2 + 900);
var len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// Home spring plus damping — the filings come back instead of drifting away
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
if (!lively && allCalm) { paintDots(); running = false; return; }
requestAnimationFrame(step);
}
05Paddle bounce
The paddle follows the mouse and the arrow keys through one shared position value, and the ball takes its horizontal kick from where along the paddle it landed. A brick reflects the ball through whichever edge it crossed more deeply, which is the game-board version of bouncing off a surface normal. It measured 6.2% cumulative area at intensity 164.0 across 23 of 23 gaps.
// Paddle reflection — the horizontal component bends with the spot that was hit (a game-board surface normal)
var pyNow = padY();
if (vy > 0 && by + 12 >= pyNow && by + 12 <= pyNow + PH && bx + 12 > px && bx < px + PW) {
var off = (bx + 6 - (px + PW / 2)) / (PW / 2);
vx = SPEED * off * 0.9;
vy = -Math.abs(vy);
by = pyNow - 12;
}
// Brick contact — switch off the overlapped cell and reflect through the edge that was crossed deeper
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
if (!b.alive) { continue; }
var bxx = scaled(BX + b.c * (BW + GAP)), byy = BY + b.r * (BH + GAP);
if (bx + 12 > bxx && bx < bxx + scaled(BW) && by + 12 > byy && by < byy + BH) {
b.alive = false;
b.el.classList.add('is-hit');
score += 1; paintScore();
var overX = bx + 6 - (bxx + scaled(BW) / 2), overY = by + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vx = overX > 0 ? Math.abs(vx) : -Math.abs(vx); }
else { vy = overY > 0 ? Math.abs(vy) : -Math.abs(vy); }
break;
}
}
06Cloth ripple
A 12×5 grid of dots is pinned along its top row, and wherever the cursor passes, the dots are shoved aside while their neighbors drag a wave along behind them. Each dot carries exactly one translate offset from its home coordinate, and the wave itself is the average displacement of the left, right, and upper neighbor. Its 49 preview tracks are the most of any item here, at 12.9% cumulative area and intensity 114.4 over 23 of 23 gaps.
var x = d.hx + d.ox, y = d.hy + d.oy;
// Push the dots within 36px of the cursor — the path the cursor takes is where the wave starts
var dx = x - ptr.x, dy = y - ptr.y, dist2 = dx * dx + dy * dy;
if (dist2 < 1296 && dist2 > 0.01) {
var dist = Math.sqrt(dist2), push = (1 - dist / 36) * 900;
d.vx += (dx / dist) * push * dt;
d.vy += (dy / dist) * push * dt;
allCalm = false;
}
// Neighbour pull — follow a little of the left, right and upper dot's current offset (the weft of the cloth)
var L = dots[d.r * COLS + Math.max(0, d.c - 1)];
var R = dots[d.r * COLS + Math.min(COLS - 1, d.c + 1)];
var U = dots[Math.max(0, d.r - 1) * COLS + d.c];
d.vx += ((L.ox + R.ox) / 2 - d.ox) * 60 * dt;
d.vy += ((L.oy + R.oy + U.oy) / 3 - d.oy) * 60 * dt;
// Home spring plus damping
d.vx += -d.ox * 90 * dt; d.vy += -d.oy * 90 * dt;
d.vx *= 0.9; d.vy *= 0.9;
d.ox += d.vx * dt; d.oy += d.vy * dt;
07Flow field
Thirty-six dashes read the cursor's direction of travel as wind, steer toward it, and wrap to the opposite edge when they leave the field. Speed is capped at 190 px/s, and every dash rotates to the Math.atan2 of its own velocity, so the swarm reads as a current instead of a scatter. Cumulative area measured 19.1% at intensity 164.0 over 23 of 23 gaps, second only to item 09.
var gust = now - windUntil < 900; // steering is strong while the latest input wind is still alive
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
// Flow field: every dash is steered the same way — the force comes from the cursor's speed
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
// Come back from the far edge — the flow field's space is joined end to end
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
08Inertia box
Grab the box and shake it: the balls inside feel the opposite of the box's acceleration and rebound off the inner walls at 0.7, and the box itself springs back to center once you let go. The acceleration term comes straight out of the drag — displacement times 46 — so your hand is the force, and no timer is involved. It measured 11.8% cumulative area at intensity 145.4, with 21 of 23 gaps moving.
if (!dragging) {
// Once the hand is off, the box returns to center on a spring
boxVX += (-90 * boxX - 9 * boxVX) * dt;
boxX += boxVX * dt;
}
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// Inertia: the ball takes a force opposite the box's acceleration, and rebounds at the walls
b.vx += -boxAX * 1.1 * dt;
b.x += b.vx * dt;
if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (b.x > inner - b.size) { b.x = inner - b.size; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (Math.abs(b.vx) > 1 || Math.abs(boxVX) > 2 || Math.abs(boxX) > 0.5) { allCalm = false; }
}
09Inertia scrub
Flick the row and let go: the release velocity is the slope of the last 120 ms of pointer samples, friction eats it, and the nearest card pulls the track onto itself. Friction is written as a power of the frame gap, so a 60 Hz and a 144 Hz screen decelerate identically instead of one gliding twice as far. At 33.3% cumulative area and intensity 180.0, this moves more pixels than anything else in the set, across 20 of 23 gaps.
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// Run no physics while the hand is down — friction decay and the snap spring would fight the dragging hand
if (dragging) { requestAnimationFrame(step); return; }
if (settling) {
// The last spring onto a card — the target is the nearest notch
var target = snapTarget();
vx += (150 * (target - x) - 13 * vx) * dt;
x += vx * dt;
if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
x = target; vx = 0; settling = false; paint(); running = false; return;
}
} else {
// Friction decay — 0.91^dt against 60fps, so the frame rate does not change the curve
vx *= Math.pow(0.91, dt * 60);
x += vx * dt;
var c = clampX(x);
if (c !== x) { x = c; vx = 0; }
if (Math.abs(vx) < 160) { settling = true; vx = 0; }
}
paint();
requestAnimationFrame(step);
}
Where it breaks — the trap
Three failures in this set came from the same habit: code that's correct on the line you're reading and wrong one layer away. In 05 the bricks were being built with a single combined class, pb__brick--r0c0, while the color rules were written for .pb__brick--r0, .pb__brick--r1, .pb__brick--r2. A CSS class selector matches a whole class token and never a prefix of one, so the cell class matched no color rule at all and the wall rendered as bare boxes. The row class has to go on the element as its own token, next to the cell class that the preview keyframes target.
// Row class and cell class separately — the row picks the color, the cell picks the preview keyframe track
el.className = 'pb__brick pb__brick--r' + r + ' pb__brick--r' + r + 'c' + c;
.pb__brick--r0 { background: $stage-orange; }
.pb__brick--r1 { background: $color; }
.pb__brick--r2 { background: $subject-cream; }
The second was 09 fighting itself. Its loop kept running the friction decay and the snap spring even while the pointer was down, so every frame the physics pulled the track toward the nearest card while the hand dragged it somewhere else; the row felt heavy, and it fought back. A single guard at the very top of step() settles it — while a hand is on the track, the loop does nothing but schedule the next frame. The general form of that lesson is that an interaction loop needs to know who is currently in charge, and the same is true of a hand-drawn stroke in signature canvas drawing.
The third was 03 disagreeing with itself about units. angle and avel are kept in degrees so they can go straight into a rotate() string, but -(g/L)·sin θ is an angular acceleration in rad/s², and adding one to the other left the restoring term short by a factor of 180/π — roughly 57 times too weak to swing anything. Released at 30°, the arm drifted to 29.1° over six seconds and never once crossed center. What makes this one worth telling is that no gate saw it. The grid preview is a CSS keyframe track, pdSwing rotating between 26° and -26°, so the render measurement quoted above — 5.6% area, 119.3 intensity, 23 of 23 gaps — describes those keyframes and not the loop a hand drives. The same split runs through every item here: each one has a keyframe preview standing in for its requestAnimationFrame loop, so a passing render measurement says nothing about what happens when someone grabs the thing. Converting the units explicitly is only half the fix. Gravity here had been 22 px/s², and on a 118 px arm that is a period of 2π√(118/22) ≈ 14.6 seconds — correct physics that still reads as a drift, so G moved to the 1800 px/s² that 02 already uses.
var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI;
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
Driving the repaired arm from a script rather than watching the preview is what settles it: released at 30°, it now crosses zero eight times in six seconds, peaks at 26.9°, and is down to 7.28° by the end.
Three dials change the feel of the whole set, and each is one number in the source:
| Dial | Where | In the demo | What changes |
|---|---|---|---|
| Stiffness | 01 k = stiffIn.value * 60 |
slider 3, so k = 180 | slider 6 gets there sooner and overshoots further |
| Damping | 01 c = dampIn.value * 1.5 |
slider 8, so c = 12 | slider 4 keeps the wobble alive for several passes |
| Restitution | 02 e = Number(restIn.value) |
0.62 | 0.9 lets the balls drum on the floor |
All nine ship in one archive, whose password is qc5pgrv8, and each item is in there twice — once as vanilla and once as React.
Accessibility (reduced-motion)
Each field is a labeled role="application" region with tabindex="0", and every pointer gesture has an arrow-key equivalent: arrows move the target (01), the magnet (04), the paddle (05), the cursor (06), the wind (07), the box (08), and one card at a time (09), while Enter or Space drops the balls (02) and starts the swing (03). Under prefers-reduced-motion: reduce, the reduce.matches branch skips the loop entirely and paints the resting state — the chip lands on the target, the balls sit on the floor without falling, the arm hangs at zero degrees, the filings and the cloth dots return to their home coordinates, the ball parks on the paddle, and the track snaps to the nearest card. The preview CSS carries the same instruction with animation: none !important, so a reader who asked for less motion gets a still frame rather than a keyframe loop, which is what WCAG's animation from interactions asks for.
FAQ
Do I need a physics engine for effects like these?
Not at this scale. The nine demos here run on addition and multiplication inside a single requestAnimationFrame callback, and the heaviest of them, the cloth in 06, tracks 60 dots. Reach for an engine when you need stacked collisions, joints, or rotation of colliding bodies — none of which appear here.
Why does the same code run faster on a 120Hz screen?
Because a per-frame constant is a per-second constant only at one refresh rate. Both fixes are in these files: every loop derives dt from the timestamp and clamps it with Math.min((now - last) / 1000, 0.033), so a stalled tab cannot teleport anything, and 09 raises its friction to the power of the frame gap instead of multiplying once per frame. Details of the callback's timestamp are in the requestAnimationFrame reference.
Can I use pointer events instead of separate mouse and touch handlers?
Yes, and that's what every item here does — a single pointerdown, pointermove, and pointerup set serves mouse, pen, and finger, with setPointerCapture keeping the stream alive when the finger leaves the element. As of 2026-09-12, caniuse's pointer table shows 96.75% global support, and the event properties are documented in Pointer events on MDN.