React hooks and 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.
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 š
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
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!