React hooks and matter.js

Date published: 3-Aug-2020
3 min read / 675 words
Author: Paul Scanlon

React
JavaScript
matter.js

Recently I was creating a Shopify demo application and became a little underwhelmed with the Checkout experience and so decided to inject a bit of "joy" into an otherwise quite dull UI component... it was my demo so why not ay? 🤷‍♂️

My plan was to add a "particle" every time the quantity of a product in the Order changed. I wanted this particle to have real world physics and I wanted each particle to have collision detection so that it would bounce off other particles.

Quite a few years ago I used a physics engined called matter.js but matter.js pre-dates React so I was curious to see if I could create the same cool physics based animations in React.

Here's what I came up with, have a play around with the Qty input.



Order

Product Name
Qty
Price
Total
Product A
£1.01
£1.01
Product B
£0.99
£4.95
Product C
£1.12
£14.56
Checkout

SubTotal
£0
Discount
£0
Total
£0




If you'd like to start using matter.js in your project I hope this will give you some indication of how to install it, use it, and the bit I really struggled with, how to make the "canvas" and "floor" responsive.

Start by installing matter.js

npm install matter-js --save

Next create a component where you'll do all the "matter" setup, and here's an absolute minimal setup that will get you started. The src file for MatterStepOne.js can be found here

Step One - Setup

// MatterStepOne.js
import React, { useEffect, useRef } from "react"
import Matter from "matter-js"
export const MatterStepOne = () => {
const boxRef = useRef(null)
const canvasRef = useRef(null)
useEffect(() => {
let Engine = Matter.Engine
let Render = Matter.Render
let World = Matter.World
let Bodies = Matter.Bodies
let engine = Engine.create({})
let render = Render.create({
element: boxRef.current,
engine: engine,
canvas: canvasRef.current,
options: {
width: 300,
height: 300,
background: "rgba(255, 0, 0, 0.5)",
wireframes: false,
},
})
const floor = Bodies.rectangle(150, 300, 300, 20, {
isStatic: true,
render: {
fillStyle: "blue",
},
})
const ball = Bodies.circle(150, 0, 10, {
restitution: 0.9,
render: {
fillStyle: "yellow",
},
})
World.add(engine.world, [floor, ball])
Engine.run(engine)
Render.run(render)
}, [])
return (
<div
ref={boxRef}
style={{
width: 300,
height: 300,
}}
>
<canvas ref={canvasRef} />
</div>
)
}

Which should render something like this 👇


There's a couple of things going on here so I'll talk you through some React specific methods that we need to use.

useRef

useRef is used twice here, first to get a ref to the containing div, boxRef and then again on the canvas, canvasRef. These are so we can tell matter.js which element to render the canvas in and which canvas we'd like to render the matter.js engine and world in. You'll see both of those refs used in the Render.create({}) method.

useEffect

As you may know using useEffect with an empty dependencies array means the code will only run once and only run when the component has mounted. It's in here where you can set up the matter.js Engine, Render, World and Bodies

Step Two - Responsive

I had a long hard look around the internet and couldn't really find anything that helped me out here so I've come up with my own solution.

The first thing to get your head around is the difference between...

<canvas ref="{canvasRef}" />

and

let render = Render.create({
...
canvas: canvasRef.current,
options: {
width: 300,
height: 300,
...
},
})

We could use CSS to make the canvas and containing div fill 100% or to put it another way, make them responsive, but... matter.js would still think the canvas has a width and height of 300

To make the canvas truly responsive we need to do update matter.js with some new values which will be used as the width and height. In addition to this the position and width of the floor also need to be updated.

To achieve this I've added a resize listener to the window

When the window is resized handleResize() is called which uses getBoundingClientRect() to get the width and height of the boxRef.

These values are then used to update the state value constraints which will cause a re-render then we can grab these new width and height values in a second useEffect which is triggered only when the new constraint values are set and use them to dynamically update both the floor and the canvas

The src file for MatterStepTwo.js can be found here

//MatterStepTwo.js
import React, { useEffect, useState, useRef } from "react"
import Matter from "matter-js"
const STATIC_DENSITY = 15
export const MatterStepTwo = () => {
const boxRef = useRef(null)
const canvasRef = useRef(null)
const [constraints, setContraints] = useState()
const [scene, setScene] = useState()
const handleResize = () => {
setContraints(boxRef.current.getBoundingClientRect())
}
useEffect(() => {
let Engine = Matter.Engine
let Render = Matter.Render
let World = Matter.World
let Bodies = Matter.Bodies
let engine = Engine.create({})
let render = Render.create({
element: boxRef.current,
engine: engine,
canvas: canvasRef.current,
options: {
background: "rgba(255, 0, 0, 0.5)",
wireframes: false,
},
})
const floor = Bodies.rectangle(0, 0, 0, STATIC_DENSITY, {
isStatic: true,
render: {
fillStyle: "blue",
},
})
const ball = Bodies.circle(150, 0, 10, {
restitution: 0.9,
render: {
fillStyle: "yellow",
},
})
World.add(engine.world, [floor, ball])
Engine.run(engine)
Render.run(render)
setContraints(boxRef.current.getBoundingClientRect())
setScene(render)
window.addEventListener("resize", handleResize)
}, [])
useEffect(() => {
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
useEffect(() => {
if (constraints) {
let { width, height } = constraints
// Dynamically update canvas and bounds
scene.bounds.max.x = width
scene.bounds.max.y = height
scene.options.width = width
scene.options.height = height
scene.canvas.width = width
scene.canvas.height = height
// Dynamically update floor
const floor = scene.engine.world.bodies[0]
Matter.Body.setPosition(floor, {
x: width / 2,
y: height + STATIC_DENSITY / 2,
})
Matter.Body.setVertices(floor, [
{ x: 0, y: height },
{ x: width, y: height },
{ x: width, y: height + STATIC_DENSITY },
{ x: 0, y: height + STATIC_DENSITY },
])
}
}, [scene, constraints])
return (
<div
style={{ position: "relative", border: "1px solid blue", padding: "8px" }}
>
<div style={{ textAlign: "center" }}>Checkout</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
rowGap: "16px",
marginBottom: "32px",
}}
>
<div>SubTitle</div>
<div>£xxx</div>
<div>Discount</div>
<div>£xxx</div>
<div>Total</div>
<div>£xxx</div>
</div>
<button
style={{
cursor: "pointer",
display: "block",
textAlign: "center",
marginBottom: "16px",
width: "100%",
}}
>
Checkout
</button>
<div
ref={boxRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
>
<canvas ref={canvasRef} />
</div>
</div>
)
}

Which should render something like this 👇

Checkout
SubTitle
£xxx
Discount
£xxx
Total
£xxx

Step Three - Triggers

The final step is to trigger a re-render when an interaction happens. In the top example this is when the value of the inputs change but in this step I'll show how to do it from an onClick on the button. (it's just easier to explain)

The src file for MatterStepThree.js can be found here

// MatterStepThree.js
import React, { useEffect, useState, useRef } from "react"
import Matter from "matter-js"
const STATIC_DENSITY = 15
const PARTICLE_SIZE = 6
const PARTICLE_BOUNCYNESS = 0.9
export const MatterStepThree = () => {
const boxRef = useRef(null)
const canvasRef = useRef(null)
const [constraints, setContraints] = useState()
const [scene, setScene] = useState()
const [someStateValue, setSomeStateValue] = useState(false)
const handleResize = () => {
setContraints(boxRef.current.getBoundingClientRect())
}
const handleClick = () => {
setSomeStateValue(!someStateValue)
}
useEffect(() => {
let Engine = Matter.Engine
let Render = Matter.Render
let World = Matter.World
let Bodies = Matter.Bodies
let engine = Engine.create({})
let render = Render.create({
element: boxRef.current,
engine: engine,
canvas: canvasRef.current,
options: {
background: "transparent",
wireframes: false,
},
})
const floor = Bodies.rectangle(0, 0, 0, STATIC_DENSITY, {
isStatic: true,
render: {
fillStyle: "blue",
},
})
World.add(engine.world, [floor])
Engine.run(engine)
Render.run(render)
setContraints(boxRef.current.getBoundingClientRect())
setScene(render)
window.addEventListener("resize", handleResize)
}, [])
useEffect(() => {
return () => {
window.removeEventListener("resize", handleResize)
}
}, [])
useEffect(() => {
if (constraints) {
let { width, height } = constraints
// Dynamically update canvas and bounds
scene.bounds.max.x = width
scene.bounds.max.y = height
scene.options.width = width
scene.options.height = height
scene.canvas.width = width
scene.canvas.height = height
// Dynamically update floor
const floor = scene.engine.world.bodies[0]
Matter.Body.setPosition(floor, {
x: width / 2,
y: height + STATIC_DENSITY / 2,
})
Matter.Body.setVertices(floor, [
{ x: 0, y: height },
{ x: width, y: height },
{ x: width, y: height + STATIC_DENSITY },
{ x: 0, y: height + STATIC_DENSITY },
])
}
}, [scene, constraints])
useEffect(() => {
// Add a new "ball" everytime `someStateValue` changes
if (scene) {
let { width } = constraints
let randomX = Math.floor(Math.random() * -width) + width
Matter.World.add(
scene.engine.world,
Matter.Bodies.circle(randomX, -PARTICLE_SIZE, PARTICLE_SIZE, {
restitution: PARTICLE_BOUNCYNESS,
})
)
}
}, [someStateValue])
return (
<div
style={{
position: "relative",
border: "1px solid white",
padding: "8px",
}}
>
<div style={{ textAlign: "center" }}>Checkout</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
rowGap: "16px",
marginBottom: "32px",
}}
>
<div>SubTitle</div>
<div>£xxx</div>
<div>Discount</div>
<div>£xxx</div>
<div>Total</div>
<div>£xxx</div>
</div>
<button
style={{
cursor: "pointer",
display: "block",
textAlign: "center",
marginBottom: "16px",
width: "100%",
}}
onClick={() => handleClick()}
>
Checkout
</button>
<div
ref={boxRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
pointerEvents: "none",
}}
>
<canvas ref={canvasRef} />
</div>
</div>
)
}

Which should render something like this 👇, go ahead and click the "Checkout" button

Checkout
SubTitle
£xxx
Discount
£xxx
Total
£xxx

The main takeout from this is that to add a new ball I've added another useEffect that is triggered when someStateValue changes. The code in this useEffect adds a new ball at a randomX position between 0 and what ever the current width is set to. In this example it's the onClick that changes the someStateValue but in the top example that useEffect is triggered whenever the quantities change and is fired by the inputs not the Checkout button... apologies if that's a little confusing!



If you've enjoyed this post I'd love to hear from you: @pauliescanlon