Scroll animations with React Three Fiber and GSAP

Scroll animations with React Three Fiber and GSAP

Β·

8 min read

Featured on Hashnode

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

Terminal screenshot using vite create app

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

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

Source code of the generated react component

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

Final render of the tutorial with the 3D office

Conclusion

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

Live preview

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 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 πŸ™

Β