By Paul Scanlon

React hooks and matter.js

  • 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.

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!

Hey!

Leave a reaction and let me know how I'm doing.

  • 0
  • 0
  • 0
  • 0
  • 0
Powered byNeon