Matter.js as a DOM animation engine
Pattern for using Matter.js purely as a physics solver while the visuals are real DOM elements (headings, glyphs, buttons). Matter owns positions/velocities; you copy each body's position onto its element via a CSS transform every frame.
Architecture that works
- Wrap it in a small framework-agnostic controller class (no Svelte/React inside).
Expose
register(el)(returns a cleanup fn, doubles as a Svelte action), plus lifecycle methods (start,return,destroy) and a config object of tunables. - Map body to element with a transform relative to a captured "home":
el.style.transform = translate3d(body.position.x - homeX, body.position.y - homeY, 0) rotate(deg). The element stays in normal flow; only the transform moves it. - Capture home from
getBoundingClientRect()only when the transform is identity (i.e. at rest, before any drift). If the element is already transformed, derive home from the body instead. Getting this wrong makes everything jump on the second run. - RAF state machine that parks itself. Use modes like
idle | active | returning. When idle, setrafId = 0and return without rescheduling, so a static page burns zero CPU. Restart the loop on the next interaction. - Build the Matter world lazily on first interaction; tear it down when it settles. Add off-screen static wall bodies sized to the viewport to keep bodies on screen.
The single biggest gotcha: applyForce is scaled by deltaTime squared
Body.applyForce does NOT add velocity in intuitive units. Matter's integrator applies
(force / mass) * deltaTime^2, and with the usual Engine.update(engine, 1000/60) the
delta is ~16.67 (milliseconds), so deltaTime^2 ~= 278. A "small" force is multiplied
by ~278 and launches everything across the screen.
For predictable, tunable control, nudge velocity directly instead:
// gentle pull toward a point, in honest px/step units
Body.setVelocity(body, {
x: body.velocity.x + (dx / dist) * pullPerStep,
y: body.velocity.y + (dy / dist) * pullPerStep
});
pullPerStep is now literally "px/step of velocity added per step" (an acceleration in
px/step^2), no hidden multiplier. Reserve applyForce for cases where you genuinely
want mass-scaled forces and have calibrated for the deltaTime^2 factor.
Frame-rate independence: one Engine.update per rAF is a latent cross-platform bug
A hand-rolled loop that calls Engine.update(engine, 1000/60) exactly once per
requestAnimationFrame couples simulation speed to the display's refresh rate. rAF fires
~120x/s on a 120Hz screen and ~30x/s under iOS Low Power Mode, but each call still advances
a fixed 16.67ms of sim time, so the world runs ~2x fast on a high-refresh display and
~2x slow when throttled. (Reproduced in the wild: a body that travels ~232 px/s at 60Hz
hits ~575 px/s at 144Hz.) The fix is to make the loop consume real elapsed time, two ways:
- Use Matter's own Runner (v0.20.0+, mid-2024). It defaults to a fixed timestep that
accumulates wall-clock time and runs N (or zero)
Engine.updates per browser frame to match elapsed real time, withmaxFrameTime/maxUpdatesbudgets and frame-delta smoothing for 59.94 vs 60Hz jitter. Simplest correct path if you do not need a custom loop. - Accumulator, if you own the loop. Measure real
dtfrom the rAF timestamp, accumulate it, and run as many fixed steps as fit:while (acc >= STEP) { engine.update(STEP); acc -= STEP }. Cap the steps per frame (e.g. bail after the equivalent of 1000/30 of catch-up) or a single slow frame triggers the spiral of death: more steps -> longer frame -> even more steps. Optionally interpolate the render by the leftoveraccto avoid stutter. (gafferongames, "Fix Your Timestep".)
iOS Low Power Mode throttles rAF to 30fps (and all CSS animations) and is effectively undetectable from JS (the Battery Status API is deprecated/removed on the open web), so you cannot feature-detect it and downgrade. Delta-correct timing is the only defense: the animation then finishes in correct wall-clock time, just at a lower, jerkier frame rate, instead of running at half speed. Safari also throttles rAF inside cross-origin iframes, uncapping only on click/tap.
Springs and dampers have the identical bug. A per-frame integrator like
v = (v + force/mass) * damping; x += v with no dt term settles ~2x fast on 120Hz. Scale
the integration by elapsed time (or run it on the same fixed-step accumulator) so the feel
is identical across refresh rates.
Mass is size, for free
Matter computes mass = density * area. Give every body the same density and a wide
glyph automatically outweighs a thin one, so collisions transfer momentum by size with
no extra code. Expose density as a tunable; the absolute value barely matters, only
the ratios between bodies.
prefers-reduced-motion will silently kill everything
If window.matchMedia('(prefers-reduced-motion: reduce)').matches, gate the whole
system to a no-op. The trap: it fails silently with no error, so "nothing moves and I
get no console output" is almost always this, not a logic bug. On Windows it is set by
Settings > Accessibility > Visual effects > Animation effects = Off (registry:
HKCU\Control Panel\Desktop\WindowMetrics\MinAnimate = 0, and the client-area-animation
bit 0x02 clear in UserPreferencesMask). Many dev machines have it off. Provide a dev
override and/or an on-page notice so reduced-motion users (and you) understand why it is
static. Listen for change on the media query to react live (read once at construction and
the animation will not respond when the user toggles the OS setting until reload).
No-op vs. reduced is a genuine choice, not a settled one. MDN, web.dev, and Sara
Soueidan read the reduce keyword as "remove, reduce, or replace" and recommend swapping
a vestibular-triggering transform for a calm opacity cross-fade so motion-sensitive
users still get a finished-looking hero instead of a static jump; other practitioners argue
a full no-op (immediate: true) is fine for JS physics specifically. Both are defensible: a
full disable is the safe floor, an opacity fade-in is the premium upgrade. This matters
because ~35% of adults experience some vestibular dysfunction by age 40, so it is harm
prevention, not polish (WCAG 2.3.3 Animation from Interactions). For SSR/prerendered output,
assume reduced (return true) on the server and resolve the real value on the client
before the first frame, so nothing animates pre-hydration.
When DOM-as-physics is the right choice (vs Canvas / WebGL)
This whole technique drives retained-mode DOM: the browser tracks and redraws your nodes, so you keep text selection, find-in-page, screen-reader access, dark mode, and CSS easing for free. That is the low-maintenance, accessible default and the correct choice at low element counts (dozens of glyphs/cards). Canvas is immediate mode (your code does all drawing, redraw cost scales with painted pixels); it wins only when element counts get large or you need pixel effects, and it costs you every browser-native feature above (teams end up shipping bespoke text-layout engines to claw them back). The experimental HTML-in-Canvas API would restore them but is Chrome-only behind an origin trial, so it is not a production cross-platform option. Choose Canvas/WebGL by element count and pixel work, never by default.
Coordinate alignment with position: sticky
Bodies live in viewport coordinates (homes captured from getBoundingClientRect). If the
animated container is position: sticky and stays pinned, each element's on-screen
position equals its body position in viewport space. That means raw clientX/clientY
already equal world coordinates for hit-testing and cursor forces, with no scroll-offset
math and no Matter.Mouse element binding. Prefer manual window pointer listeners over
MouseConstraint, which otherwise forces you to reconcile its element-relative,
scroll-dependent coordinate mapping.
Cursor pull and grab-to-throw (manual, not MouseConstraint)
- Radius-limited cursor pull: skip bodies beyond a radius; inside it scale strength by
(1 - dist/radius)^2(quadratic falloff) so only nearby elements react meaningfully. - Grab-to-throw with a click threshold: on
pointerdownrecord a candidate body (Matter.Query.point(bodies, {x,y})) but do NOT start dragging. Only promote to a drag once the pointer moves past ~4px. This preserves plain clicks, so a draggable button still fires itsonclick. While dragging, set the body's velocity toward the cursor each step (so it shoves others); on release, set velocity from recent pointer movement to fling.
Touch: a finger plowing through bodies during a scroll
To make a touch scroll shove bodies aside (the finger parts the elements as it drags past),
you cannot read the finger from pointer events: once a touch becomes a scroll (with
touch-action: pan-y), the browser fires pointercancel and stops sending pointermove,
so the pointer position goes stale exactly during the gesture you care about. Track the finger
from touch* events instead — touchstart/touchmove/touchend (passive) keep firing
through a scroll. Accumulate the finger's per-frame travel (vx/vy from successive
touchmove client positions) and zero it once a solver step consumes it, so a paused finger
stops exerting force. Then apply a radial shove away from the finger, scaled by its speed
(strength * (1 - dist/radius)^2 * fingerSpeed), to nearby bodies — a still finger does
nothing, a brisk swipe parts them. Keep this separate from the desktop cursor force (which uses
pointer events): pick one per frame by whether a finger is currently down.
Timed sequences on the frame clock (not setTimeout)
Effects that play out over time ("the element arrives, then a beat later a second effect
fires, then a third") tempt one setTimeout per step. Don't: setTimeout runs on a different
clock from the physics — it keeps counting while the tab is backgrounded (where rAF is paused),
ignores prefers-reduced-motion, and scatters timing across the codebase. Instead give the
controller one seam: schedule(delayMs, cb) that records fireAt = performance.now() + delayMs
and, inside the existing rAF loop, fires (and removes) any cue whose fireAt has passed. It then
shares the physics clock for free — pauses when the loop parks, advances at wall-clock rate
across refresh rates, and is inert under reduced motion (have schedule no-op there). Return a
cancel function; keep the loop awake while any cue is pending; snapshot due cues before running
them so a callback can safely schedule or cancel more. A "scene" is then a few schedule calls
whose cancel handles are cleared as a unit if the trigger reverses — declarative sequencing in
one place instead of stray timers.
Gravity-well / suck-into-a-point
To make bodies rush into a single point, collide on the way, and arc rather than beeline:
- Each step add a strong velocity nudge toward the point, then multiply velocity by a damping factor < 1 (e.g. 0.93). A pure central force conserves angular momentum and bodies orbit forever; the damping bleeds energy so they spiral in.
- Give each body an initial tangential kick (perpendicular to the radius) so the pull curves their momentum into an arc.
- Make the target body a sensor (
body.isSensor = true) so others pass through it instead of piling on its back. - Remove absorbed bodies (
Composite.remove(world, body)) once they reach the point, and flip a body toisSensoras it enters the final approach, so a pile-up at the sink clears and the bodies behind can get through. Otherwise bouncy collisions jam the entrance and they never fully arrive.
Force-directed relationship graph over DOM bodies
To turn a scatter of DOM bodies into a node-link graph (letters into words, words into a
constellation), lay the graph on top of bodies that already exist rather than building a
parallel system. This "additive effect" borrows the bodies by reference, adds Matter
constraints for the edges, draws an SVG overlay for the lines and labels, and never writes
the bodies' transforms (their owning code already does that each frame). Constraints are a
second kind of world membership alongside bodies, so funnel them through the controller (an
addConstraint wrapper) and the one Engine.update resolves them for free. Because the
effect does not touch transforms, it needs no ownership handoff with whatever else drives
those elements. Trigger it from the composition root via a callback (a state-change callback
on whatever spawns it), not by having the moving systems know about each other.
A force-directed layout is four forces. Express each as a velocity nudge per fixed step (see the deltaTime-squared note) or as constraints the solver runs:
- Attraction is Matter
Constraints (springs) between connected bodies. The solver does the pulling; you only add and remove them. - Repulsion is pairwise, with a linear or inverse falloff. Measure the gap from each body's surface (its half-extents), not its center, so a big node (a card) pushes neighbours over a wide range while small nodes only nudge each other up close. Split the push inversely by mass so a heavy node barely drifts while the light ones do the fanning. O(n^2), but fine into the tens of nodes.
- Centering / containment is a spring toward a target point whose force grows with displacement (near-zero in the middle, strongest at the edges).
- Directional flow is a uniform push in one direction emanating from an origin (a hub) and fading with distance: "repulsion in a direction", for streaming the cloud off to one side.
Gotchas that cost real time:
- Rest length comes from node size, not current distance. Deriving an edge's rest length from the bodies' current (scattered) separation freezes them apart. Compute it from the nodes' widths (times a spread factor) so the spring actually gathers them.
- An add-only velocity spring oscillates. Adding
(target - pos) * kto velocity every step with no damping is an undamped oscillator: it overshoots and rings. Multiply the velocity by a damping factor< 1after adding the pull so it settles smoothly. This is the fix for "the pinned node just bounces around trying to reach its spot". Verify by sampling the position over time: it should converge monotonically, not swing. - A center spring cannot reposition a wall-bound cloud. The equilibrium centroid sits at the target only if the cloud is not pressed against the viewport walls; a cloud wide enough to touch both side walls has its bias cancelled by the wall normals. Gather it firmer (raise the spring) so it is compact enough to actually sit where you aim it.
- Pin a node to a responsive anchor with a damped spring, subtle stiffness plus damping, so it glides in from wherever it spawned and stops without a bounce.
The SVG edge overlay:
- A
position: fixed; inset: 0SVG with noviewBoxmakes one SVG user unit equal one viewport pixel, which equals the bodies' coordinates, so you write each line's endpoints straight frombody.positionevery frame. - Stack it between the background and the text. A fixed SVG at
z-index: -1sits behind normal-flow content (so behind glyphs whosewill-change: transformgives them their own stacking contexts) but in front of az-index: -10background. - Fade the whole layer in with one group
opacity, not per-edge. - To make an edge meet a card's border instead of its center, clip that endpoint to the
body's box: cast a ray from the body center toward the other endpoint and stop at the box
edge (
t = 1 / max(|dx|/halfW, |dy|/halfH)); if the other point is already inside the box, leave it.
A traveling pulse along the graph (a wave that visits nodes and jostles them):
- Build a path as an ordered list of bodies (e.g. hub, then spine anchors, then a cluster's nodes) so the pulse follows real edges. Walk it by arc length over the bodies' live positions each step, segment by segment, so the path bends as the bodies move.
- As the pulse point passes, push nearby nodes away from it. The push direction flips as it approaches then leaves a node, which reads as a wiggle that the springs settle behind it.
- Ride a visible dot on the pulse point; append it last in the SVG group so it draws over the edges. Cycle which route each pulse takes for variety.
Responsive layout: keep the per-viewport policy (which way to flow, where to pin, how short the links) in the consumer as small hook functions, not baked into the generic effect. Evaluate structural values (constraint rest length and stiffness) once when the graph builds, and continuous forces (anchor target, flow direction) each frame from the live viewport size, so the layout follows resizes and rotation.
Debug overlay you can toggle from the console
Attach a Matter.Render to a position: fixed; inset: 0; pointer-events: none; opacity: 0.75 canvas at a high z-index. Toggle it from a window.someDebug() function so
you can inspect bodies, velocities, and collisions live. Bodies only exist while the world
is simulating, so the overlay is empty at rest. Note it only shows the physics, not
transform-driven (non-Matter) animation phases.
Misc
- Guard all
window/documentaccess for SSR; construct the controller lazily or behind atypeof window !== 'undefined'check. restitutioncontrols bounciness on collision, not whether momentum transfers. Even at restitution 0 bodies shove each other; raise it (0.6-0.8) so collisions visibly separate.- With
frictionAir: 0(space feel) there is no damping, so whatever initial velocity you seed is kept until a collision. A gentle launch stays gentle. - Re-roll randomness (direction, speed, spin) with
Math.random()per run rather than index modulo (i % 5,i % 2), which reads as a visible repeating pattern. - Stick to
transformandopacity. Only those two animate on the compositor alone (no layout, no paint), which is why the transform-per-frame mapping is cheap. Animatingtop/left/width/heightinstead forces layout and feeds Cumulative Layout Shift (CLS); transforms do not shift layout. Keep the animated elements present in the prerendered HTML so there is no inject-on-mount shift either. - Do not blanket-promote layers.
will-change: transform/translateZ(0)promotes an element to its own GPU layer, but each layer costs memory; promoting dozens ("layer explosion") backfires on memory-constrained mobile. Promote only what actually animates.