DEV Community

Cover image for Scroll animations with React Three Fiber and GSAP
Wassim SAMAD
Wassim SAMAD

Posted on • Updated on • Originally published at wawasensei.hashnode.dev

Scroll animations with React Three Fiber and GSAP

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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%);
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Save and run the project with

yarn dev
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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} />
Enter fullscreen mode Exit fullscreen mode

To have control over our office animation we need to install gsap library

yarn add gsap
Enter fullscreen mode Exit fullscreen mode

Go to the Office.jsx and store a refto the main group.

const ref = useRef();

return (
    <group
      {...props}
      dispose={null}
      ref={ref}
Enter fullscreen mode Exit fullscreen mode

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
    );
Enter fullscreen mode Exit fullscreen mode

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());
  });
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: [],
};

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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));
  });
Enter fullscreen mode Exit fullscreen mode

We add the opacity as a prop to our sections

<Section opacity={opacityFirstSection}>
...
<Section right opacity={opacitySecondSection}>
...
<Section opacity={opacityLastSection}>
...
Enter fullscreen mode Exit fullscreen mode

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,
      }}
    >
Enter fullscreen mode Exit fullscreen mode

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

Collapse
 
thuannguyen0501 profile image
Nguyen Cong Thuan

awesome πŸ”₯πŸ”₯πŸ”₯

Collapse
 
baltz profile image
baltz

fucking awesome πŸ”₯πŸ”₯πŸ”₯

Collapse
 
marchitecht profile image
marchitecht • Edited

Hey! Nice and decent article! Should it work properly with Next.js and webpack?