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>
);
}
The idea is simple:
- React renders the layout and the canvas.
-
initPlanet3Dtakes 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 };
};
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;
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;
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);
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);
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);
});
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
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;
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;
}
// 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);
}
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);
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>
}
Highlights:
-
sunOrientationviadot(uSunDirection, normal)tells us which side is lit. -
dayMixusessmoothstepto blend day and night softly. - Clouds are pulled from the green channel of the specular texture and only appear on the day side.
-
fresnelgives 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);
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);
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>
}
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);
});
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>
);
}
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"
);
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 };
};
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();
}
};
}, []);
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)