Scroll animations with React Three Fiber and GSAP
Tags: threejs, react, webdev, tutorial
Published: 2023-02-15 12:40:05

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:

{% embed https://www.youtube.com/watch?v=pXpckHDDNYo %}

## Project Setup

Let's start by creating a React app with Vite 

```
yarn create vite
```
Select the react/javascript template

![Terminal screenshot using vite create app](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/075pp5ywf7kbkyuqxemi.png)


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`)

![The first render of a 3D cube](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kezgnqe1ik58ughnxaci.png)

## Loading the 3D Model

You can get the model from [here](https://github.com/wass08/r3f-scrolling-animation-tutorial/tree/main/public/models)

Don't forget to say thanks to [Thaís](https://twitter.com/thayumiko) for building this beautiful model for us 🙏

Now in your terminal run
```
npx gltfjsx publics/models/WawaOffice.glb
```

[gtlfjsx](https://github.com/pmndrs/gltfjsx) is a client to automatically create a react component from your 3D model. It even supports TypeScript.

You should have this WawaOffice.js generated

![Source code of the generated react component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kc6zexe5dz8qcsr8ab3s.png)

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](https://github.com/pmndrs/drei#scrollcontrols).

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](https://greensock.com/docs/v3/GSAP/gsap.to())
```
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](https://tailwindcss.com/docs/guides/vite)!

```
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](https://fonts.google.com/).

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](https://github.com/pmndrs/drei#scrollcontrols))

```
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,
      }}
    >
```


![Final render of the tutorial with the 3D office](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/visagr8smzvndss4b9bt.png)



## Conclusion

Congratulations you now have a great starting point to build your own portfolio with React Three Fiber and Tailwind.

[Live preview](https://codesandbox.io/p/github/wass08/r3f-scrolling-animation-tutorial/draft/icy-platform?file=%2Fsrc%2FApp.jsx&workspaceId=5cd54208-3423-4f91-a7e8-14ca07bdc9af)

The code is available here:
[https://github.com/wass08/r3f-scrolling-animation-tutorial](https://github.com/wass08/r3f-scrolling-animation-tutorial)

I highly recommend you to read [React Three Fiber documentation](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) and check their [examples](https://docs.pmnd.rs/react-three-fiber/getting-started/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](https://www.youtube.com/watch?v=LNvn66zJyKs&list=PLpepLKamtPjiUF6PvVUbIFhx9HaS0qJs_) playlist on YouTube.

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