Let's animate our 3D model and our user interface to follow the page scroll with:
- Vite
- React
- Tailwind
- Three.js
- React Three Fiber
- GSAP
π₯ This tutorial is a good starting point to prepare a good looking portfolio.
A video version is also available where you can watch the final render:
Project Setup
Let's start by creating a React app with Vite
yarn create vite
Select the react/javascript template
Now add the dependencies for React Three Fiber
yarn add three @react-three/drei @react-three/fiber
yarn dev
Go to index.css
and remove everything inside (keep the file we will use it later)
In App.css
replace everything with
#root {
width: 100vw;
height: 100vh;
background-color: #d9afd9;
background-image: linear-gradient(0deg, #d9afd9 0%, #97d9e1 100%);
}
Now create a folder named components
and inside create an Experience.jsx
file. It's where we'll build our 3D experience.
Inside let's create a cube and add OrbitControls
from React Three Drei:
import { OrbitControls } from "@react-three/drei";
export const Experience = () => {
return (
<>
<OrbitControls />
<mesh>
<boxBufferGeometry />
<meshNormalMaterial />
</mesh>
</>
);
};
Now let's open App.jsx
and replace the content with a Canvas
that will hold our Three.js components and the Experience
component we just built
import { Canvas } from "@react-three/fiber";
import "./App.css";
import { Experience } from "./components/Experience";
function App() {
return (
<Canvas>
<Experience />
</Canvas>
);
}
export default App;
Save and run the project with
yarn dev
You should see a cube and be able to rotate around it with your mouse (thanks to OrbitControls
)
Loading the 3D Model
You can get the model from here
Don't forget to say thanks to ThaΓs for building this beautiful model for us π
Now in your terminal run
npx gltfjsx publics/models/WawaOffice.glb
gtlfjsx is a client to automatically create a react component from your 3D model. It even supports TypeScript.
You should have this WawaOffice.js generated
Copy everything and create Office.jsx
in the components
folder and paste the component.
Rename the component from Model
to Γffice
and fix the path to ./models/WawaOffice.glb
Now your office should be like that
/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
*/
import React, { useRef } from 'react'
import { useGLTF } from '@react-three/drei'
export function Office(props) {
const { nodes, materials } = useGLTF('./models/WawaOffice.glb')
return (
<group {...props} dispose={null}>
<mesh geometry={nodes['01_office'].geometry} material={materials['01']} />
<mesh geometry={nodes['02_library'].geometry} material={materials['02']} position={[0, 2.11, -2.23]} />
<mesh geometry={nodes['03_attic'].geometry} material={materials['03']} position={[-1.97, 4.23, -2.2]} />
</group>
)
}
useGLTF.preload('./models/WawaOffice.glb')
Now in Experience.jsx
replace the mesh
with the <Office />
component and add an <ambientLight intensity={1}/>
to avoid seeing the model in black.
By the ways, this model contains baked textures (this is why it is quite big). What it means is that all lighting and shadows were made in Blender and baked using raytracing into a texture file to have this good looking result.
Animate the model on scroll
Let's wrap our Office
component into ScrollControls
from React Three Drei
<ScrollControls pages={3} damping={0.25}>
<Office />
</ScrollControls>
pages
is the number of pages you want. Consider a page equals the height of the viewport.
damping
is the smoothing factor. I had good results with 0.25
Additional info in the documentation.
You should see a scrollbar appearing but you can't scroll because the OrbitControls
are catching the scroll event.
Simply disable it as follows
<OrbitControls enableZoom={false} />
To have control over our office animation we need to install gsap library
yarn add gsap
Go to the Office.jsx
and store a ref
to the main group.
const ref = useRef();
return (
<group
{...props}
dispose={null}
ref={ref}
Let's create ou gsap
timeline inside a useLayoutEffect
and we will update the group y position from it's current position to -FLOOR_HEIGHT * (NB_FLOORS - 1)
for a duration of 2 seconds.
export const FLOOR_HEIGHT = 2.3;
export const NB_FLOORS = 3;
export function Office(props) {
...
useLayoutEffect(() => {
tl.current = gsap.timeline();
// VERTICAL ANIMATION
tl.current.to(
ref.current.position,
{
duration: 2,
y: -FLOOR_HEIGHT * (NB_FLOORS - 1),
},
0
);
We use a duration of 2 seconds because we have 3 pages:
- The first page and initial position is 0 second
- The second is 1 second
- The third page is the end of the animation (2 seconds)
We scroll in reverse order the office based on the Y axis because we scroll the office and not the camera. As we go from bottom to top we need to decrease the vertical position of the office.
Now let's play our animation. We have access to the scroll with useScroll
hook it contains an offset property with a value between 0
and 1
to represent the current scroll percentage.
const scroll = useScroll();
useFrame(() => {
tl.current.seek(scroll.offset * tl.current.duration());
});
Now our Office
scroll vertically following our page scroll.
Let's use the same principles to animate the floors positions and rotation.
Here is what I ended with, but feel free to adjust it to what you prefer!
/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
*/
import { useGLTF, useScroll } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import gsap from "gsap";
import React, { useLayoutEffect, useRef } from "react";
export const FLOOR_HEIGHT = 2.3;
export const NB_FLOORS = 3;
export function Office(props) {
const { nodes, materials } = useGLTF("./models/WawaOffice.glb");
const ref = useRef();
const tl = useRef();
const libraryRef = useRef();
const atticRef = useRef();
const scroll = useScroll();
useFrame(() => {
tl.current.seek(scroll.offset * tl.current.duration());
});
useLayoutEffect(() => {
tl.current = gsap.timeline();
// VERTICAL ANIMATION
tl.current.to(
ref.current.position,
{
duration: 2,
y: -FLOOR_HEIGHT * (NB_FLOORS - 1),
},
0
);
// Office Rotation
tl.current.to(
ref.current.rotation,
{ duration: 1, x: 0, y: Math.PI / 6, z: 0 },
0
);
tl.current.to(
ref.current.rotation,
{ duration: 1, x: 0, y: -Math.PI / 6, z: 0 },
1
);
// Office movement
tl.current.to(
ref.current.position,
{
duration: 1,
x: -1,
z: 2,
},
0
);
tl.current.to(
ref.current.position,
{
duration: 1,
x: 1,
z: 2,
},
1
);
// LIBRARY FLOOR
tl.current.from(
libraryRef.current.position,
{
duration: 0.5,
x: -2,
},
0.5
);
tl.current.from(
libraryRef.current.rotation,
{
duration: 0.5,
y: -Math.PI / 2,
},
0
);
// ATTIC
tl.current.from(
atticRef.current.position,
{
duration: 1.5,
y: 2,
},
0
);
tl.current.from(
atticRef.current.rotation,
{
duration: 0.5,
y: Math.PI / 2,
},
1
);
tl.current.from(
atticRef.current.position,
{
duration: 0.5,
z: -2,
},
1.5
);
}, []);
return (
<group
{...props}
dispose={null}
ref={ref}
position={[0.5, -1, -1]}
rotation={[0, -Math.PI / 3, 0]}
>
<mesh geometry={nodes["01_office"].geometry} material={materials["01"]} />
<group position={[0, 2.11, -2.23]}>
<group ref={libraryRef}>
<mesh
geometry={nodes["02_library"].geometry}
material={materials["02"]}
/>
</group>
</group>
<group position={[-1.97, 4.23, -2.2]}>
<group ref={atticRef}>
<mesh
geometry={nodes["03_attic"].geometry}
material={materials["03"]}
/>
</group>
</group>
</group>
);
}
useGLTF.preload("./models/WawaOffice.glb");
You now have nice animations based on your page scroll.
Preparing the UI with Tailwind
Let's create a UI. You can use whatever you want to style it but I chose my lover Tailwind!
yarn add -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
It will generate a tailwind.config.cjs
replace the content with
/** @type {import('tailwindcss').Config} */
const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
serif: ["Playfair Display", ...defaultTheme.fontFamily.sans],
sans: ["Poppins", ...defaultTheme.fontFamily.sans],
},
},
plugins: [],
};
It tells tailwind to watch into the .html
and .jsx
files and it changed the default fonts to one I chose from Google Fonts.
Now in index.css
add:
@import url("https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&family=Poppins&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
The first line is the Google font import
Ok now we have Tailwind installed let's create our UI.
Create a component named Overlay
with the following content
import { Scroll } from "@react-three/drei";
const Section = (props) => {
return (
<section className={`h-screen flex flex-col justify-center p-10 ${
props.right ? "items-end" : "items-start"
}`}
<div className="w-1/2 flex items-center justify-center">
<div className="max-w-sm w-full">
<div className="bg-white rounded-lg px-8 py-12">
{props.children}
</div>
</div>
</div>
</section>
);
};
export const Overlay = () => {
return (
<Scroll html>
<div class="w-screen">
<Section>
<h1 className="font-semibold font-serif text-2xl">
Hello, I'm Wawa Sensei
</h1>
<p className="text-gray-500">Welcome to my beautiful portfolio</p>
<p className="mt-3">I know:</p>
<ul className="leading-9">
<li>π§βπ» How to code</li>
<li>π§βπ« How to learn</li>
<li>π¦ How to deliver</li>
</ul>
<p className="animate-bounce mt-6">β</p>
</Section>
<Section right>
<h1 className="font-semibold font-serif text-2xl">
Here are my skillsets π₯
</h1>
<p className="text-gray-500">PS: I never test</p>
<p className="mt-3">
<b>Frontend π</b>
</p>
<ul className="leading-9">
<li>ReactJS</li>
<li>React Native</li>
<li>VueJS</li>
<li>Tailwind</li>
</ul>
<p className="mt-3">
<b>Backend π¬</b>
</p>
<ul className="leading-9">
<li>NodeJS</li>
<li>tRPC</li>
<li>NestJS</li>
<li>PostgreSQL</li>
</ul>
<p className="animate-bounce mt-6">β</p>
</Section>
<Section>
<h1 className="font-semibold font-serif text-2xl">
π€ Call me maybe?
</h1>
<p className="text-gray-500">
I'm very expensive but you won't regret it
</p>
<p className="mt-6 p-3 bg-slate-200 rounded-lg">
π <a href="tel:(+42) 4242-4242-424242">(+42) 4242-4242-424242</a>
</p>
</Section>
</div>
</Scroll>
);
};
Note that our main div is wrapped inside a Scroll
component with the html
prop to be able to add html
inside our Canvas and have access to the scroll later.
Now add the Overlay
component next to the Office
<ScrollControls pages={3} damping={0.25}>
<Overlay />
<Office />
</ScrollControls>
The interface is ready and as each Section
height is 100vh
the scroll is already good. But let's add some opacity animation.
Animating the UI on scroll
We will change the opacity of our sections based on the scroll.
To do so we store their opacity in a state
const [opacityFirstSection, setOpacityFirstSection] = useState(1);
const [opacitySecondSection, setOpacitySecondSection] = useState(1);
const [opacityLastSection, setOpacityLastSection] = useState(1);
Then in useFrame
we animate them using the scroll hook methods available (more info here)
useFrame(() => {
setOpacityFirstSection(1 - scroll.range(0, 1 / 3));
setOpacitySecondSection(scroll.curve(1 / 3, 1 / 3));
setOpacityLastSection(scroll.range(2 / 3, 1 / 3));
});
We add the opacity as a prop to our sections
<Section opacity={opacityFirstSection}>
...
<Section right opacity={opacitySecondSection}>
...
<Section opacity={opacityLastSection}>
...
Now in our Section
component we adjust the opacity using this prop
<section
className={`h-screen flex flex-col justify-center p-10 ${
props.right ? "items-end" : "items-start"
}`}
style={{
opacity: props.opacity,
}}
>
Conclusion
Congratulations you now have a great starting point to build your own portfolio with React Three Fiber and Tailwind.
The code is available here:
https://github.com/wass08/r3f-scrolling-animation-tutorial
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 tutorial 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 (3)
awesome π₯π₯π₯
fucking awesome π₯π₯π₯
Hey! Nice and decent article! Should it work properly with Next.js and webpack?