Matter.js HTML Elements with CSS matrix3d

This guide explains how to synchronize 2D physics bodies from Matter.js with standard HTML DOM elements using CSS matrix3d transforms. By mapping the position and rotation data of a physics body directly into a 4x4 transformation matrix, you can achieve high-performance, GPU-accelerated rendering of HTML elements without triggering expensive layout recalculations in the browser.

Why Use matrix3d

Standard CSS properties like top and left trigger browser layout and repaint passes on every frame, leading to stuttering physics simulations. Using CSS transforms bypasses layout updates and leverages GPU compositing. While a 2D matrix() or combination of translate() and rotate() works, matrix3d() explicitly promotes the element to its own hardware-accelerated compositing layer, reducing latency and subpixel jitter during fast-paced simulations.

Matrix Structure for 2D Physics

A CSS matrix3d expects 16 values in column-major order representing an affine 4x4 transformation matrix:

matrix3d(
  m11, m12, m13, m14,
  m21, m22, m23, m24,
  m31, m32, m33, m34,
  m41, m42, m43, m44
)

To map a 2D rotation (angle \(\theta\) around the Z-axis) and 2D translation (\(X, Y\)) from Matter.js, configure the matrix as follows:

DOM and CSS Setup

Matter.js defines a body's coordinates at its center of mass. To align an HTML element properly, ensure its transform origin is at its center and its initial layout coordinates start at the origin (0, 0):

.physics-element {
  position: absolute;
  top: 0;
  left: 0;
  transform-origin: 50% 50%;
  will-change: transform;
  pointer-events: auto;
}

If your element has dimensions \(W\) and \(H\), offsetting the origin is automatically handled if the element has explicit dimensions matching the physics body and uses transform-origin: 50% 50%. The translation will move the center of the element directly to the physics body coordinates \((x, y)\).

The Update Loop

Hook into the Matter.js update cycle using Events.on(engine, 'afterUpdate', ...) or request a standard requestAnimationFrame loop to update the DOM elements:

import { Engine, Events, Bodies, Composite } from 'matter-js';

// Setup engine and world
const engine = Engine.create();
const domElement = document.querySelector('.physics-element');

// Create a body with matching dimensions (e.g., 100x100)
const body = Bodies.rectangle(400, 200, 100, 100);
Composite.add(engine.world, body);

// Synchronize on every physics tick
Events.on(engine, 'afterUpdate', () => {
  const { x, y } = body.position;
  const angle = body.angle;

  const cos = Math.cos(angle);
  const sin = Math.sin(angle);

  // If the element's width/height offsets are required when not using 50% transform-origin:
  // const tx = x - width / 2;
  // const ty = y - height / 2;
  
  // Directly mapping center-to-center:
  const tx = x;
  const ty = y;

  // Construct column-major matrix3d string
  domElement.style.transform = `matrix3d(
    ${cos}, ${sin}, 0, 0,
    ${-sin}, ${cos}, 0, 0,
    0, 0, 1, 0,
    ${tx}, ${ty}, 0, 1
  )`;
});

Performance Considerations