DEV Community

Cover image for Build an award Winning 3D Website with scroll-based animations | Next.js, three.js & GSAP
Robinzon
Robinzon

Posted on

Build an award Winning 3D Website with scroll-based animations | Next.js, three.js & GSAP

If you wish to go through this tutorial on YouTube please visit this link

๐Ÿ‘†๐Ÿ‘†๐Ÿ‘†


Building a 3D Earth Hero Section in Next.js, Three.js, and GLSL

If you want your portfolio or product site to be remembered, a flat hero section is not always enough. A small 3D moment that feels alive can quickly turn a generic page into something people talk about.

In the video, we build a fully interactive 3D Earth hero section with:

  • Next.js (App Router)
  • React and TypeScript
  • Three.js
  • Custom GLSL shaders for day, night, clouds, and atmosphere
  • Lenis and GSAP ScrollTrigger for smooth scroll based animation

This post walks through the main steps from the video, using the real code from the project.

Repo: https://github.com/Robinzon100/3D_hero


1. Turn the hero into a client side canvas

We start by making the home page a client component and giving it a dedicated canvas for the planet.

"use client";
import { useEffect } from "react";

import initPlanet3D from "@/components/3D/planet";

export default function Home() {
  useEffect(() => {
    initPlanet3D();
  }, []);

  return (
    <div className="page">
      <section className="hero_main">
        <canvas className="planet-3D" />
      </section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The idea is simple:

  • React renders the layout and the canvas.
  • initPlanet3D takes over the canvas and mounts Three.js inside it.
  • Everything 3D lives in components/3D/planet.ts.

2. Bootstrapping the Three.js scene

Inside planet.ts we first grab the canvas and create a scene:

import * as THREE from "three";

const initPlanet = (): { scene: THREE.Scene } => {
  const canvas = document.querySelector(
    "canvas.planet-3D"
  ) as HTMLCanvasElement;
  const scene = new THREE.Scene();

  return { scene };
};
Enter fullscreen mode Exit fullscreen mode

Next, we add the core building blocks: sizes, camera, and renderer.

// Sizes
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
  pixelRatio: window.devicePixelRatio,
};

// Camera
const camera = new THREE.PerspectiveCamera(
  15,
  sizes.width / sizes.height,
  0.1,
  10000
);
camera.position.x = 0;
camera.position.y = 0.1;
camera.position.z = 19;
scene.add(camera);

// Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(sizes.pixelRatio);
renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace;
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Low FOV (15) makes the planet feel large and cinematic.
  • The camera is slightly above on the Y axis and pulled back on Z, so the planet sits nicely in frame.
  • Transparent clear color lets us overlay regular HTML UI behind the planet.

3. Loading high quality Earth textures

To make the sphere look like Earth, we load three textures:

  • Day color map
  • Night lights map
  • Specular cloud map (for both clouds and reflections)
const TL = new THREE.TextureLoader();
const dayTexture = TL.load("./earth/day.jpg");
const nightTexture = TL.load("./earth/night.jpg");
const specularCloudsTexture = TL.load("./earth/specularClouds.jpg");

dayTexture.colorSpace = THREE.SRGBColorSpace;
nightTexture.colorSpace = THREE.SRGBColorSpace;

const baseAnisotropy = renderer.capabilities.getMaxAnisotropy();

dayTexture.anisotropy = baseAnisotropy;
specularCloudsTexture.anisotropy = baseAnisotropy;
nightTexture.anisotropy = baseAnisotropy;
Enter fullscreen mode Exit fullscreen mode

anisotropy makes textures stay sharp at grazing angles. Without it, the planet would look muddy along the edges.


4. First pass: a basic shader material

Before we go full Earth shader, we create a basic shader to make sure all plumbing works.

const earthGeometry = new THREE.SphereGeometry(2, 64, 64);
const earthMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vPosition;

    void main() {
      vec4 modelPosition = modelMatrix * vec4(position, 1.0);
      gl_Position = projectionMatrix * viewMatrix * modelPosition;

      vec3 modelNormal = (modelMatrix * vec4(normal, 0.0)).xyz;

      vUv = uv;
      vNormal = modelNormal;
      vPosition = modelPosition.xyz;
    }
  `,
  fragmentShader: `
    varying vec2 vUv;
    varying vec3 vNormal;
    varying vec3 vPosition;

    void main() {
      vec3 viewDirection = normalize(vPosition - cameraPosition);
      vec3 normal = normalize(vNormal);
      gl_FragColor = vec4(normal, 1.0);
    }
  `,
  transparent: true,
});

const earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);
Enter fullscreen mode Exit fullscreen mode

The fragment shader just uses the normal as color. It is not pretty, but it proves:

  • The geometry is correct.
  • The shader compiles.
  • Our camera and renderer are set up correctly.

5. Smooth scrolling and the render loop

Next we wire Lenis and GSAP so that:

  • Scroll feels smooth.
  • The render loop and scroll are in sync.
const lenis = new Lenis({
  duration: 1.2,
  easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
});

lenis.on("scroll", ScrollTrigger.update);

gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
  renderer.render(scene, camera);
});

gsap.ticker.lagSmoothing(0);
Enter fullscreen mode Exit fullscreen mode

GSAP drives everything using its ticker, Lenis updates scroll, and we render on each tick.

Later, we add rotation and resize handling inside the same loop:

gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
  earth.rotation.y = time * 0.2;
  renderer.render(scene, camera);
});

window.addEventListener("resize", () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  sizes.pixelRatio = Math.min(window.devicePixelRatio, 2);

  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();

  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(sizes.pixelRatio);
});
Enter fullscreen mode Exit fullscreen mode

Now the planet slowly spins and the scene stays pixel perfect on resize.


6. Splitting shaders into GLSL files

Writing GLSL inline inside TypeScript gets messy fast. So we install raw-loader and tell Turbopack to load .glsl files as raw strings.

npm i raw-loader -D
Enter fullscreen mode Exit fullscreen mode

next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  turbopack: {
    rules: {
      "*.{glsl,vs,fs,vert,frag}": {
        loaders: ["raw-loader"],
        as: "*.js",
      },
    },
  },
  reactCompiler: true,
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Then we move our shaders to shaders/earth/vertex.glsl and shaders/earth/fragment.glsl:

// earth.vertex.glsl
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * viewMatrix * modelPosition;

  vec3 modelNormal = (modelMatrix * vec4(normal, 0.0)).xyz;

  vUv = uv;
  vNormal = modelNormal;
  vPosition = modelPosition.xyz;
}
Enter fullscreen mode Exit fullscreen mode
// earth.fragment.glsl
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec3 viewDirection = normalize(vPosition - cameraPosition);
  vec3 normal = normalize(vNormal);
  gl_FragColor = vec4(normal, 1.0);
}
Enter fullscreen mode Exit fullscreen mode

And reference them in planet.ts:

const earthMaterial = new THREE.ShaderMaterial({
  vertexShader: earthVertexShader,
  fragmentShader: earthFragmentShader,
  uniforms: {
    uDayTexture: new THREE.Uniform(dayTexture),
    uNightTexture: new THREE.Uniform(nightTexture),
    uSpecularCloudsTexture: new THREE.Uniform(specularCloudsTexture),
    uSunDirection: new THREE.Uniform(new THREE.Vector3(-1, 0, 0)),
    uAtmosphereDayColor: new THREE.Uniform(
      new THREE.Color(earthParameters.atmosphereDayColor)
    ),
    uAtmosphereTwilightColor: new THREE.Uniform(
      new THREE.Color(earthParameters.atmosphereTwilightColor)
    ),
  },
  transparent: true,
});

const earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);
Enter fullscreen mode Exit fullscreen mode

7. The real Earth shader

Now that the structure is clean, we build the real fragment shader: day, night, clouds, atmosphere, reflection, and specular highlights.

uniform sampler2D uDayTexture;
uniform sampler2D uNightTexture;
uniform sampler2D uSpecularCloudsTexture;
uniform vec3 uSunDirection;
uniform vec3 uAtmosphereDayColor;
uniform vec3 uAtmosphereTwilightColor;

varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec3 viewDirection = normalize(vPosition - cameraPosition);
  vec3 normal = normalize(vNormal);
  vec3 color = vec3(0.0);

  vec3 dayColor = texture(uDayTexture, vUv).rgb;
  vec3 nightColor = texture(uNightTexture, vUv).rgb;
  vec2 specularCloudsColor = texture(uSpecularCloudsTexture, vUv).rg;

  // Sun orientation
  float sunOrientation = dot(uSunDirection, normal);

  // Day / night color
  float dayMix = smoothstep(-0.25, 0.5, sunOrientation);
  color += mix(nightColor, dayColor, dayMix) * 2.0;

  // Clouds
  float cloudsMix = smoothstep(0.5, 1.0, specularCloudsColor.g * 1.1);
  cloudsMix *= dayMix;
  color = mix(color, vec3(1.0), cloudsMix);

  // Fresnel
  float fresnel = dot(viewDirection, normal) + 1.1;
  fresnel = pow(fresnel, 2.0);

  // Atmosphere
  float atmosphereDayMix = smoothstep(-0.5, 1.0, sunOrientation);
  vec3 atmosphereColor = mix(
    uAtmosphereTwilightColor,
    uAtmosphereDayColor,
    atmosphereDayMix
  );
  color = mix(color, atmosphereColor, fresnel * atmosphereDayMix);

  // Specular
  vec3 reflection = reflect(-uSunDirection, normal);
  float specular = -dot(reflection, viewDirection);
  specular = max(specular, 0.0);
  specular = pow(specular, 10.0);
  specular *= specularCloudsColor.r * 0.7;

  vec3 specularColor = mix(vec3(1.0), atmosphereColor, fresnel);
  color += specular * specularColor;

  // Final color
  gl_FragColor = vec4(color, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
Enter fullscreen mode Exit fullscreen mode

Highlights:

  • sunOrientation via dot(uSunDirection, normal) tells us which side is lit.
  • dayMix uses smoothstep to blend day and night softly.
  • Clouds are pulled from the green channel of the specular texture and only appear on the day side.
  • fresnel gives that soft blue rim glow near the edges.
  • Reflections and specular use the red channel of the specular texture to limit highlights to oceans.

We also add a spherical helper in planet.ts to control the sun direction:

// Coordinates
let sunSpherical = new THREE.Spherical(1, Math.PI * 0.48, -1.8);
const sunDirection = new THREE.Vector3();

// Sun direction
sunDirection.setFromSpherical(sunSpherical);

// Uniforms
earthMaterial.uniforms.uSunDirection.value.copy(sunDirection);
atmosphereMaterial.uniforms.uSunDirection.value.copy(sunDirection);
Enter fullscreen mode Exit fullscreen mode

8. Atmosphere mesh and shaders

To make the glow feel believable, the atmosphere is a separate mesh slightly larger than Earth and rendered on the back side.

const atmosphereMaterial = new THREE.ShaderMaterial({
  side: THREE.BackSide,
  transparent: true,
  vertexShader: atmosphereVertexShader,
  fragmentShader: atmosphereFragmentShader,
  uniforms: {
    uOpacity: { value: 1 },
    uSunDirection: new THREE.Uniform(new THREE.Vector3(0, 0, 1)),
    uAtmosphereDayColor: new THREE.Uniform(
      new THREE.Color(atmosphereDayColor)
    ),
    uAtmosphereTwilightColor: new THREE.Uniform(
      new THREE.Color(atmosphereTwilightColor)
    ),
  },
  depthWrite: false,
});

const atmosphere = new THREE.Mesh(earthGeometry, atmosphereMaterial);
atmosphere.scale.set(1.13, 1.13, 1.13);

const earthGroup = new THREE.Group().add(earth, atmosphere);
scene.add(earthGroup);
Enter fullscreen mode Exit fullscreen mode

Atmosphere fragment shader:

uniform vec3 uSunDirection;
uniform vec3 uAtmosphereDayColor;
uniform vec3 uAtmosphereTwilightColor;
uniform float uOpacity;

varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec3 viewDirection = normalize(vPosition - cameraPosition);
  vec3 normal = normalize(vNormal);
  vec3 color = vec3(0.0);

  // Sun orientation
  float sunOrientation = dot(uSunDirection, normal);

  float atmosphereDayMix = smoothstep(-0.5, 1.0, sunOrientation);
  vec3 atmosphereColor = mix(
    uAtmosphereTwilightColor,
    uAtmosphereDayColor,
    atmosphereDayMix
  );
  color = mix(color, atmosphereColor, atmosphereDayMix);
  color += atmosphereColor;

  float edgeAlpha = dot(viewDirection, normal);
  edgeAlpha = smoothstep(0.0, 1.3, edgeAlpha);

  float dayAlpha = smoothstep(-0.5, 0.0, sunOrientation);

  float alpha = edgeAlpha * dayAlpha;

  // Final color
  gl_FragColor = vec4(color, alpha);
  gl_FragColor.a *= uOpacity;
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}
Enter fullscreen mode Exit fullscreen mode

This gives us:

  • A soft ring of atmosphere that fades at the edges.
  • Atmosphere color that respects the sun direction.
  • Alpha that fades toward the dark side of the planet.

The rotation now happens on earthGroup so Earth and atmosphere stay aligned:

gsap.ticker.add((time) => {
  lenis.raf(time * 1000);
  earthGroup.rotation.y = time * 0.2;
  renderer.render(scene, camera);
});
Enter fullscreen mode Exit fullscreen mode

9. Scroll based hero animation and UI

The 3D is only half of the hero. We also need content and a scroll moment that feels intentional.

Updated page.tsx:

"use client";

import { useEffect } from "react";
import initPlanet3D from "@/components/3D/planet";

export default function Home() {
  useEffect(() => {
    initPlanet3D();
  }, []);

  return (
    <div className="page">
      <section className="hero_main">
        <div className="content">
          <h1>Welcome To The New World</h1>

          <p>
            AI agents that actually bring value to businesses and elevate
            workers productivity.
          </p>

          <button className="cta_btn">Get started.</button>
        </div>
        <canvas className="planet-3D" />
      </section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then we wire ScrollTrigger:

gsap.registerPlugin(ScrollTrigger);

gsap
  .timeline({
    scrollTrigger: {
      trigger: ".hero_main",
      start: () => "top top",
      scrub: 3,
      anticipatePin: 1,
      pin: true,
    },
  })
  .to(
    ".hero_main .content",
    {
      filter: "blur(40px)",
      autoAlpha: 0,
      scale: 0.5,
      duration: 2,
      ease: "power1.inOut",
    },
    "setting"
  )
  .to(
    camera.position,
    {
      y: 0.1,
      z: window.innerWidth > 768 ? 19 : 30,
      x: window.innerWidth > 768 ? 0 : 0.1,
      duration: 2,
      ease: "power1.inOut",
    },
    "setting"
  );
Enter fullscreen mode Exit fullscreen mode

As the user scrolls:

  • The text blurs and fades out.
  • The camera pulls back and reframes the planet.
  • The hero pins so the transition feels more like a scene than a regular scroll.

10. Cleaning up WebGL properly

To avoid memory leaks in single page apps, we dispose the renderer when the component unmounts. That requires initPlanet to return the renderer as well:

import * as THREE from "three";

const initPlanet = (): {
  scene: THREE.Scene;
  renderer: THREE.WebGLRenderer;
} => {
  const canvas = document.querySelector(
    "canvas.planet-3D"
  ) as HTMLCanvasElement;
  const scene = new THREE.Scene();

  const sizes = {
    width: window.innerWidth,
    height: window.innerHeight,
    pixelRatio: window.devicePixelRatio,
  };

  const camera = new THREE.PerspectiveCamera(
    15,
    sizes.width / sizes.height,
    0.1,
    10000
  );
  scene.add(camera);

  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(sizes.pixelRatio);

  return { scene, renderer };
};
Enter fullscreen mode Exit fullscreen mode

And in the React effect:

useEffect(() => {
  const { scene, renderer } = initPlanet3D();

  return () => {
    if (renderer) {
      const gl = renderer.getContext?.();
      gl?.getExtension("WEBGL_lose_context")?.loseContext();
      renderer.dispose();
    }
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

WEBGL_lose_context helps free GPU resources and keeps the dev server happy during hot reloads.


Wrap up

The final hero combines:

  • A custom Earth shader with day, night, clouds, atmosphere, reflections, and specular highlights.
  • A dedicated atmosphere mesh for the glow around the rim.
  • Smooth Lenis scrolling and GSAP ScrollTrigger for the camera and text animation.
  • Clean React integration and renderer cleanup in Next.js.

You can use this as a template for your own 3D heroes: swap the textures, adjust the lighting, or plug in a completely different GLSL effect. The core structure stays the same:

Canvas in React, Three.js scene, GLSL shaders for style, GSAP for movement.

If you want to follow along line by line, the full code is in the repo:

https://github.com/Robinzon100/3D_hero

Top comments (0)