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
The table model contains all the different legs layout
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
You should see a cube and be able to rotate around it thanks to <OrbitControls />
Staging
Now let’s 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
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’t see shadows yet even if the canvas and stage have shadows enabled.
We need to tell our mesh to castShadows.
Now we can see the very smooth shadow generated by the stage component.
Load the table
Let’s 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’s copy everything, delete the file, and create a new one in the components
folder named Table.jsx
By default it’s named Model
, rename it to Table
. We need to fix the path adding ./models
for both useGltf
and the preload
call.
The final component code should looks like this
Now let’s replace the cube with the table in Experience.jsx
.
There’s no shadows yet. So let’s add the castShadow
prop on every meshes on the Table
component.
<mesh casthShadows />
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’s create a folder named contexts
and a file named Configurator.jsx
Let's create a context Boilerplate with createContext
ConfiguratorProvider
and useConfigurator
.
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’s 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’s 0, we render the first one, if it’s 1 we render the second layout, and if it’s 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.
Wrapping the App in the Configurator provider
It works, now we only see the first legs layout!
Let’s add the Interface
component I prepared for you next to the canvas.
<Canvas shadows camera={{ position: [4, 4, -12], fov: 35 }}>
<Experience />
</Canvas>
<Interface />
You now can see the different options available.
Now let's go to our Interface
code.
I commented the full settings so we don’t mess up with the naming.
Let’s grab legs and setLegs from our configurator.
const [legs, setLegs] = useConfigurator();
...and let’s uncomment the value and onChange on our layout radio buttons.
Now when we switch, the legs change correctly...
...but the shadows are not re-rendered because the scene doesn’t 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’s 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.
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’s 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)}
>
Now the color of our legs changes every time we apply it.
Table width
Last step, let’s change the width from our table!
In our Configurator
context create a tableWidth
state with a default value of 100
(centimeters)
Our final context should looks like this
Get it into the Table
component, and let’s 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
.
In the Interface
let’s 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.
Adjusting the x
position of the table legs
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’s 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’s 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’s too slow, let’s 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’s 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);
});
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.
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.
Thank you, don't hesitate to ask your questions in the comments section 🙏
Top comments (1)
Thanks for the lesson!
Just wanted to point out a typo:
npx gltjfs public/models/Table.gltf
After
npx
it should begltfjsx