Creating a 3D Table Configurator with React Three Fiber

Creating a 3D Table Configurator with React Three Fiber

9 min read

Featured on Hashnode

Let's create a 3D Table Configurator using the following libraries:

  • Vite

  • React

  • Tailwind

  • Three.js

  • React Three Fiber

  • Material UI

馃敟 This tutorial is a good starting point to create a product configurator for an exciting shopping experience.

The main topics covered are:

  • how to load a 3D model

  • how to modify it using a user interface

  • how to scale and move items smoothly

A video version is also available where you can watch the final render:

Project Setup

I prepared you a starter pack including:

  • a React app created with Vite

  • a MUI User Interface

  • three.js/React Three Fiber installed including a Canvas displaying a cube to get started

  • the 3D model of the table in public/models

3D Table model

The table model contains all the different legs layout

Table model hierarchy

I separated in different meshes the left and right legs to make it simple when we will expand the table width.

Clone the repo and run

yarn
yarn dev

A simple 3D cube

You should see a cube and be able to rotate around it thanks to <OrbitControls />

Staging

Now let鈥檚 create a better staging environment. I rarely start from scratch, I recommend you to go through React Three Fiber examples to find a good starting point.

I chose one named stage presets, gltfjsx

3D car

It contains nice lighting and shadows settings.

We start by copying the canvas with the camera settings and enabling shadows.

<Canvas shadows camera={{ position: [4, 4, -12], fov: 35 }}>
...
</Canvas>

I set y position of the camera to 4

Then we copy the stage component and orbitcontrols and instead of using their model, we use the cube for now.

<Stage intensity={1.5} environment="city" shadows={{
  type: "accumulative",
  color: "#85ffbd",
  colorBlend: 2,
  opacity: 2,
}}
  adjustCamera={2}
>
...
<OrbitControls makeDefault minPolarAngle={0} maxPolarAngle={Math.PI / 2} />

I assigned the color to the one of the gradient in the CSS index file. It helps to build good looking shadows on the floor. I also changed adjustCamera to 2 to zoom out a little bit.

minPolarAngle and maxPolarAngle are limits you can define on OrbitControls to avoid going below or over the model.

Adjust those parameters to what you prefer.

We can鈥檛 see shadows yet even if the canvas and stage have shadows enabled.

We need to tell our mesh to castShadows.

Adding cast shadow to our mesh

Now we can see the very smooth shadow generated by the stage component.

Cube with shadows

Load the table

Let鈥檚 render our table instead of the default cube.

To do so we use the gltfjsx client with

npx gltjfs public/models/Table.gltf

It generates a Table.js file containing a React component with the extracted meshes from the table model file.

Let鈥檚 copy everything, delete the file, and create a new one in the components folder named Table.jsx

By default it鈥檚 named Model, rename it to Table. We need to fix the path adding ./models for both useGltf and the preload call.

Table component code

The final component code should looks like this

Now let鈥檚 replace the cube with the table in Experience.jsx.

Experience code

There鈥檚 no shadows yet. So let鈥檚 add the castShadow prop on every meshes on the Table component.

<mesh casthShadows />

Table model with shadows enabled

Now our table renders shadows correctly, but all the legs layouts are displayed at the same time.

Legs layout

To be able to display only one layout at a time let鈥檚 create a folder named contexts and a file named Configurator.jsx

Let's create a context Boilerplate with createContext ConfiguratorProvider and useConfigurator.

Context boilerplate

Ok, now we want to have the choice between our legs layout, so we define legs and setLegs with useState. Our layouts will be 0, 1 and 2. so let鈥檚 default to 0.

const [legs, setLegs] = useState(0);

Go back to our table and get the legs from useConfigurator.

const { legs } = useConfigurator();

And we will simply do conditional rendering for our legs.

If it鈥檚 0, we render the first one, if it鈥檚 1 we render the second layout, and if it鈥檚 2 we render the last one.

{legs === 0 && (
        <>
          <mesh
            castShadow
            geometry={nodes.Legs01Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
          />
          <mesh
            geometry={nodes.Legs01Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}
      {legs === 1 && (
        <>
          <mesh
            geometry={nodes.Legs02Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
          />
          <mesh
            geometry={nodes.Legs02Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}
      {legs === 2 && (
        <>
          <mesh
            geometry={nodes.Legs03Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
          />
          <mesh
            geometry={nodes.Legs03Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
          />
        </>
      )}

In a real project you could refactor it more nicely.

Now jump into main.jsx and wrap the app in our ConfiguratorProvider to make our context available everywhere.

main source code

Wrapping the App in the Configurator provider

3D Table with first layout

It works, now we only see the first legs layout!

Let鈥檚 add the Interface component I prepared for you next to the canvas.

<Canvas shadows camera={{ position: [4, 4, -12], fov: 35 }}>
  <Experience />
</Canvas>
<Interface />

Interface enabled

You now can see the different options available.

Now let's go to our Interface code.

I commented the full settings so we don鈥檛 mess up with the naming.

Let鈥檚 grab legs and setLegs from our configurator.

const [legs, setLegs] = useConfigurator();

...and let鈥檚 uncomment the value and onChange on our layout radio buttons.

Code uncomment on the interface

Now when we switch, the legs change correctly...

...but the shadows are not re-rendered because the scene doesn鈥檛 know something changes.

A simple way to tell it is to get the legs value in the experience component to force the re-rendering.

const { legs } = useConfigurator();

Add this line to the Experience component.

Now the shadows are re-generated when we switch.

Legs color

Let鈥檚 change the legs color. Go to the Configurator and create legsColor and setLegsColor with the same default value we have in the interface. Feel free to change it. Don't forget to add it to the exposed values from the context.

Context code with legs colors

Let's apply the color to the Table.

const { legs, legsColor, setLegsColor } = useConfigurator();

We add a useEffect with legsColor so every time it changes, this function will get called.

useEffect(() => {
  materials.Metal.color = new Three.Color(legsColor);
}, [legsColor]);

We need to add this import line manually:

import * as Three from "three";

On the interface let鈥檚 add the legsColor and setLegsColor and uncomment the value and onChange.

<FormControl>
  <FormLabel>Legs Color</FormLabel>
  <RadioGroup
    value={legsColor}
    onChange={(e) => setLegsColor(e.target.value)}
>

Table with gold legs

Now the color of our legs changes every time we apply it.

Table width

Last step, let鈥檚 change the width from our table!

In our Configurator context create a tableWidth state with a default value of 100 (centimeters)

Context code with table width

Our final context should looks like this

Get it into the Table component, and let鈥檚 calculate a scalingPercentage by diving the tableWidth per one hundred.

const tableWidthScale = tableWidth / 100;

On the plate, change the scale with the tableWidthScale on the x axis, and keep the y and z to 1.

Apply table width scale

In the Interface let鈥檚 uncomment the slider value and onChange.

const { tableWidth, setTableWidth, legs, setLegs, legsColor, setLegsColor } =
    useConfigurator();
...
<FormControl>
  <FormLabel>Table width</FormLabel>
  <Slider
  sx={{
  width: "200px",
  }}
  min={50}
  max={200}
  value={tableWidth}
  onChange={(e) => setTableWidth(e.target.value)}
  valueLabelDisplay="auto"
  />
</FormControl>

Now our table width change but we need to move the legs accordingly, we can do it simply by multiplying the x position by the tableWidthScale.

Code apply x position of the table

Adjusting the x position of the table legs

3D table with a longer width

Now it works correctly, but still the movement is not very smooth

Smooth animation

To make a smooth animation when we changes the table width, let鈥檚 store references of our plate, left legs and right legs.

const plate = useRef();
const leftLegs = useRef();
const rightLegs = useRef();

Because our leftLegs and rightlegs are never rendered at the same time, we can save the 3 legs layouts in the same references.

Let鈥檚 remove the tableWidthScale multiplier as we will do it another way.

{legs === 0 && (
        <>
          <mesh
            castShadow
            geometry={nodes.Legs01Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs01Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}
      {legs === 1 && (
        <>
          <mesh
            geometry={nodes.Legs02Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs02Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}
      {legs === 2 && (
        <>
          <mesh
            geometry={nodes.Legs03Left.geometry}
            material={materials.Metal}
            position={[-1.5, 0, 0]}
            castShadow
            ref={leftLegs}
          />
          <mesh
            geometry={nodes.Legs03Right.geometry}
            material={materials.Metal}
            position={[1.5, 0, 0]}
            castShadow
            ref={rightLegs}
          />
        </>
      )}

We use the useFrame hook which will be called at each frame.

It provides the state, and the delta time. which is the time between elapsed from the last frame.

We declare a targetScale Vector3 with tableWidthScale on the x axis. And on the plate scale we use the lerp function to transition smoothly from our currentScale into our targetScale.

import { useFrame } from "@react-three/fiber"; 
...
useFrame((_state, delta) => {
  const tableWidthScale = tableWidth / 100;
  const targetScale = new Vector3(tableWidthScale, 1, 1);

  plate.current.scale.lerp(targetScale, delta);
});

Now it animates smoothly, but it鈥檚 too slow, let鈥檚 define an ANIM_SPEED constant of 12 and multiply the delta by it.

const ANIM_SPEED = 12;
...
plate.current.scale.lerp(targetScale, delta * ANIM_SPEED);

Let鈥檚 do the same process for both legs impacting the position instead of the scale:


const ANIM_SPEED = 12;

export function Table(props) {
  const { nodes, materials } = useGLTF("./models/Table.gltf");

  const { legs, legsColor, tableWidth } = useConfigurator();

  const plate = useRef();
  const leftLegs = useRef();
  const rightLegs = useRef();

  useEffect(() => {
    materials.Metal.color = new Three.Color(legsColor);
  }, [legsColor]);

  useFrame((_state, delta) => {
    const tableWidthScale = tableWidth / 100;
    const targetScale = new Vector3(tableWidthScale, 1, 1);

    plate.current.scale.lerp(targetScale, delta * ANIM_SPEED);

    const targetLeftPosition = new Vector3(-1.5 * tableWidthScale, 0, 0);
    leftLegs.current.position.lerp(targetLeftPosition, delta * ANIM_SPEED);

    const targetRightPosition = new Vector3(1.5 * tableWidthScale, 0, 0);
    rightLegs.current.position.lerp(targetRightPosition, delta * ANIM_SPEED);
  });

Final result of the 3D table configurator

Yes! Our table configurator now works perfectly!

Conclusion

Congratulations you now have a great starting point to build a 3D product configurator using React Three Fiber and Material UI.

Live preview

The code is available here: https://github.com/wass08/table-configurator-three-js-r3F-tutorial-final

I highly recommend you to read React Three Fiber documentation and check their examples to discover what you can achieve and how to do it.

For more React Three Fiber tutorials you can check my Three.js/React Three Fiber playlist on YouTube and my online course React Three Fiber: The Ultimate Guide to 3D Development.

Thank you, don't hesitate to ask your questions in the comments section 馃檹