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鈥檚 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鈥檛 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鈥檚 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.
The final component code should looks like this
Now let鈥檚 replace the cube with the table in Experience.jsx
.
There鈥檚 no shadows yet. So let鈥檚 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鈥檚 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鈥檚 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.
Wrapping the App in the Configurator provider
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 />
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.
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.
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)}
>
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)
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
.
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.
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鈥檚 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);
});
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 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 馃檹