Anatomy of Gravity
How a matter-js world lives inside a React tree, why the engine is created at render instead of in an effect, and how a rAF loop turns physics bodies into moving DOM.
↓Scroll to step through it
One world, thick walls, a few bodies
Gravity is an invisible physics world running underneath ordinary DOM
elements. matter-js never draws anything; every frame, a sync loop copies
each body's position straight into style.transform on the element you
see.
The canvas is a plain relative overflow-hidden div. On mount, Gravity
adds static rectangle walls just past each edge (200px thick, so a body
moving fast can't tunnel through in a single frame), and every MatterBody
child becomes one more rectangle or circle body in that same world.
A ResizeObserver rebuilds the walls whenever the canvas changes size, so
they always sit just outside whatever the container currently measures.
The engine is created at render, not in an effect:
const [engine] = React.useState(() => Matter.Engine.create());This is the one non-obvious line in the file. React fires child effects
first, so a child MatterBody's effect calls ctx.register(...) before the
parent Gravity's own effect runs. If the engine were created inside
Gravity's effect, every body registered by a child would already have
tried to add itself to a world that doesn't exist yet. Creating it during
render (via lazy useState) guarantees the engine and its world exist
before any child can register a body against it.
x/y accept a % string, a px string, or a number, so a body can be
placed relative to a container whose size isn't known at author time:
function resolve(value: number | string, size: number): number {
if (typeof value === "number") return value;
if (value.endsWith("%")) return (parseFloat(value) / 100) * size;
return parseFloat(value);
}This runs once, at registration. After that, the body is a free physics object and its position is whatever the simulation computes, not the original percentage.
Position and angle become style.transform
Matter.js has no idea a DOM exists. A sync function, scheduled with
requestAnimationFrame, reads every registered body's position and
angle each frame and writes them into the matching element's transform.
Nothing else moves the element.
Subtracting half the element's own width and height re-centers the
translation, because Matter tracks a body's center while transform
moves from the element's top-left origin. translate/rotate are both
compositor-only: the browser moves an already-painted layer without
re-running layout, which keeps dozens of simultaneously falling bodies
smooth.
A single shared Matter.MouseConstraint handles every pointer interaction
in the world. Each MatterBody carries its own isDraggable flag, checked
the moment a drag starts:
Matter.Events.on(mouseConstraint, "startdrag", (e) => {
const dragged = [...bodiesRef.current.values()].find(
(b) => b.body === (e as unknown as { body: Matter.Body }).body,
);
if (dragged && !dragged.isDraggable) {
mouseConstraint.constraint.bodyB = null;
}
});If the body that was grabbed isn't draggable, the constraint's target is cleared on the same tick. The pointer never attaches, so the body keeps falling under gravity instead of following the cursor.
Cheap when idle
A physics world sitting off screen, or behind a hidden tab, has no business
burning frames. An IntersectionObserver on the canvas and a
visibilitychange listener on the document both gate the same two
functions: resume() starts the Matter Runner and reschedules the rAF
sync loop; pause() stops both.
Either condition failing is enough to pause. Visible-but-hidden-tab and intersecting-but-off-screen both stop the simulation, and either one becoming true again resumes it where the bodies were left.
The result
An invisible matter-js world mirrored onto the DOM with one transform
write per frame, paused while the canvas is off screen or the tab is hidden.
- World
- relative overflow-hidden container, sized by CSS
- Walls
- static 200px-thick rectangles just past each edge
- Body
- one MatterBody = one rectangle or circle body
const t = 200; // thick walls keep fast bodies containedconst walls = [Matter.Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts),Matter.Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts),Matter.Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts),];if (addTopWall) {walls.push(Matter.Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts));}Anatomy of Gravity
How a matter-js world lives inside a React tree, why the engine is created at render instead of in an effect, and how a rAF loop turns physics bodies into moving DOM.
- World
- relative overflow-hidden container, sized by CSS
- Walls
- static 200px-thick rectangles just past each edge
- Body
- one MatterBody = one rectangle or circle body
const t = 200; // thick walls keep fast bodies containedconst walls = [Matter.Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts),Matter.Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts),Matter.Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts),];if (addTopWall) {walls.push(Matter.Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts));}One world, thick walls, a few bodies
Gravity is an invisible physics world running underneath ordinary DOM
elements. matter-js never draws anything; every frame, a sync loop copies
each body's position straight into style.transform on the element you
see.
The canvas is a plain relative overflow-hidden div. On mount, Gravity
adds static rectangle walls just past each edge (200px thick, so a body
moving fast can't tunnel through in a single frame), and every MatterBody
child becomes one more rectangle or circle body in that same world.
A ResizeObserver rebuilds the walls whenever the canvas changes size, so
they always sit just outside whatever the container currently measures.
The engine is created at render, not in an effect:
const [engine] = React.useState(() => Matter.Engine.create());This is the one non-obvious line in the file. React fires child effects
first, so a child MatterBody's effect calls ctx.register(...) before the
parent Gravity's own effect runs. If the engine were created inside
Gravity's effect, every body registered by a child would already have
tried to add itself to a world that doesn't exist yet. Creating it during
render (via lazy useState) guarantees the engine and its world exist
before any child can register a body against it.
x/y accept a % string, a px string, or a number, so a body can be
placed relative to a container whose size isn't known at author time:
function resolve(value: number | string, size: number): number {
if (typeof value === "number") return value;
if (value.endsWith("%")) return (parseFloat(value) / 100) * size;
return parseFloat(value);
}This runs once, at registration. After that, the body is a free physics object and its position is whatever the simulation computes, not the original percentage.
transform: translate(x - w/2, y - h/2) rotate(angle)
- Body (physics)
- Matter.Body position + angle, never rendered
- DOM plate
- style.transform, rewritten every rAF
const sync = () => {for (const { element, body } of bodiesRef.current.values()) { const w = element.offsetWidth; const h = element.offsetHeight; element.style.transform = `translate(${body.position.x - w / 2}px, ${ body.position.y - h / 2 }px) rotate(${body.angle}rad)`;}syncFrame = requestAnimationFrame(sync);};Position and angle become style.transform
Matter.js has no idea a DOM exists. A sync function, scheduled with
requestAnimationFrame, reads every registered body's position and
angle each frame and writes them into the matching element's transform.
Nothing else moves the element.
Subtracting half the element's own width and height re-centers the
translation, because Matter tracks a body's center while transform
moves from the element's top-left origin. translate/rotate are both
compositor-only: the browser moves an already-painted layer without
re-running layout, which keeps dozens of simultaneously falling bodies
smooth.
A single shared Matter.MouseConstraint handles every pointer interaction
in the world. Each MatterBody carries its own isDraggable flag, checked
the moment a drag starts:
Matter.Events.on(mouseConstraint, "startdrag", (e) => {
const dragged = [...bodiesRef.current.values()].find(
(b) => b.body === (e as unknown as { body: Matter.Body }).body,
);
if (dragged && !dragged.isDraggable) {
mouseConstraint.constraint.bodyB = null;
}
});If the body that was grabbed isn't draggable, the constraint's target is cleared on the same tick. The pointer never attaches, so the body keeps falling under gravity instead of following the cursor.
Runner.stop(runner) · cancelAnimationFrame(syncFrame)
- Running
- intersecting and tab visible: runner + sync loop active
- Paused
- off-screen or tab hidden: both loops stopped
const io = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible && !document.hidden) resume(); else pause();},{ threshold: 0 },);document.addEventListener("visibilitychange", () => {if (document.hidden) pause();else if (visible) resume();});Cheap when idle
A physics world sitting off screen, or behind a hidden tab, has no business
burning frames. An IntersectionObserver on the canvas and a
visibilitychange listener on the document both gate the same two
functions: resume() starts the Matter Runner and reschedules the rAF
sync loop; pause() stops both.
Either condition failing is enough to pause. Visible-but-hidden-tab and intersecting-but-off-screen both stop the simulation, and either one becoming true again resumes it where the bodies were left.
The result
An invisible matter-js world mirrored onto the DOM with one transform
write per frame, paused while the canvas is off screen or the tab is hidden.
Reduced motion
Under prefers-reduced-motion, Gravity never creates an engine and
MatterBody never registers. Each body lays itself out statically at its
authored x/y/angle using ordinary absolute positioning. No physics
runs and no walls exist.
const staticPlacement =
!ctx || ctx.reduced
? {
left: typeof x === "number" ? `${x}px` : x,
top: typeof y === "number" ? `${y}px` : y,
transform: `translate(-50%, -50%) rotate(${angle}deg)`,
}
: undefined;Motion Score
physics framematter-js simulation each frame