Frontend Development 28 min read

Building an Interactive 3D Web Page with Three.js, Tween.js, and DRACOLoader

This tutorial demonstrates how to create a two‑page 3D web experience using Three.js, Blender‑compressed models, DRACOLoader for efficient loading, Tween.js for smooth animations, custom cursor handling, lighting effects, and responsive rendering, complete with code snippets and deployment instructions.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Building an Interactive 3D Web Page with Three.js, Tween.js, and DRACOLoader

Abstract

This article builds on the first two chapters of the series "Three.js Advanced Journey" and shows how to combine lighting, model loading, and Tween.js to create a creative 3D page. You will learn how to compress models with Blender, manage loading progress, use DRACOLoader, apply outputEncoding, animate with TWEEN, move point lights with the mouse, create a virtual cursor, monitor element visibility, and add CSS animations.

Effect

The final page consists of two sections. Page 1 displays a head statue model (🗿) illuminated by a light (💡) that changes shading as the mouse moves. Page 2 contains a Tab menu on the left and poetic text on the right; clicking a menu item rotates the statue, and the point light follows the mouse.

Online preview links are provided for better viewing on large screens.

Preview 1: https://dragonir.github.io/3d/#/shadow

Preview 2: https://3d-eosin.vercel.app/#/shadow

The source code is hosted in the GitHub repository threejs-odessey .

Code Snippets

Preparation

The project requires a 3D model (🗿) which can be created with Blender or 3D Max, or downloaded from Sketchfab.

Download Model

The statue model is obtained from Sketchfab, a platform for sharing 3D, VR, and AR content based on WebGL/WebVR.

Compress Model

After importing the model into Blender, delete unnecessary objects, enable compression, and export. The compressed file size can be reduced from tens of megabytes to a few hundred kilobytes.

Implementation

Page Structure

The page layout includes a loading screen ( #loading-text-intro ), a navigation bar ( nav.header ), and two full‑viewport sections ( section.first and section.second ) each containing a .webgl canvas.

<div class='shadow_page'>
  <div id="loading-text-intro"><p>Loading</p></div>
  <div class="content" style="visibility: hidden">
    <nav class="header"></nav>
    <section class="section first">
      <div class='info'></div>
      <canvas id='canvas-container' class='webgl'></canvas>
    </section>
    <section class="section second">
      <div class='second-container'></div>
      <canvas id='canvas-container-details' class='webgl'></canvas>
    </section>
  </div>
</div>

Resource Import

Import global CSS, Three.js core, TWEEN, DRACOLoader, and GLTFLoader.

import './style.css';
import { Clock, Scene, LoadingManager, WebGLRenderer, sRGBEncoding, Group, PerspectiveCamera, DirectionalLight, PointLight, MeshPhongMaterial } from 'three';
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';

Scene Initialization

Two renderers and cameras are created because each page has its own 3D scene. Resize events update both cameras and renderers.

const section = document.getElementsByClassName('section')[0];
let width = section.clientWidth;
let height = section.clientHeight;
// Renderer 1
const renderer = new WebGLRenderer({ canvas: document.querySelector('#canvas-container'), antialias: true, alpha: true, powerPreference: 'high-performance' });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.autoClear = true;
renderer.outputEncoding = sRGBEncoding;
// Renderer 2 (no antialias)
const renderer2 = new WebGLRenderer({ canvas: document.querySelector('#canvas-container-details'), antialias: false });
renderer2.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer2.setSize(width, height);
renderer2.outputEncoding = sRGBEncoding;
// Scene and Cameras
const scene = new Scene();
const cameraGroup = new Group();
scene.add(cameraGroup);
const camera = new PerspectiveCamera(35, width/height, 1, 100);
camera.position.set(19, 1.54, -0.1);
cameraGroup.add(camera);
const camera2 = new PerspectiveCamera(35, width/height, 1, 100);
camera2.position.set(3.2, 2.8, 3.2);
camera2.rotation.set(0, 1, 0);
scene.add(camera2);
window.addEventListener('resize', () => {
  const sec = document.getElementsByClassName('section')[0];
  camera.aspect = sec.clientWidth / sec.clientHeight;
  camera.updateProjectionMatrix();
  camera2.aspect = sec.clientWidth / sec.clientHeight;
  camera2.updateProjectionMatrix();
  renderer.setSize(sec.clientWidth, sec.clientHeight);
  renderer2.setSize(sec.clientWidth, sec.clientHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer2.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

Key Knowledge: outputEncoding

The outputEncoding property controls the color encoding of the renderer. The default THREE.LinearEncoding looks flat; setting it to THREE.sRGBEncoding yields more realistic colors, while THREE.GammaEncoding works with a gamma factor of 2.2.

Loading Management

A LoadingManager displays a loading screen while the model loads. When loading finishes, the loading overlay is animated out using TWEEN.

const loadingManager = new LoadingManager();
loadingManager.onLoad = () => {
  document.querySelector('.content').style.visibility = 'visible';
  const yPos = { y: 0 };
  const loadingCover = document.getElementById('loading-text-intro');
  new TWEEN.Tween(yPos).to({ y: 100 }, 900)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .start()
    .onUpdate(() => {
      loadingCover.style.setProperty('transform', `translate(0, ${yPos.y}%)`);
    })
    .onComplete(() => {
      loadingCover.parentNode.removeChild(loadingCover);
      TWEEN.remove(this);
    });
  // Camera entrance animation
  new TWEEN.Tween(camera.position.set(0, 4, 2))
    .to({ x:0, y:2.4, z:5.8 }, 3500)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .start()
    .onComplete(() => {
      TWEEN.remove(this);
      document.querySelector('.header').classList.add('ended');
      document.querySelector('.description').classList.add('ended');
    });
};

Model Loading

Use DRACOLoader together with GLTFLoader to load the compressed .glb model. After loading, replace the material with MeshPhongMaterial for a shiny surface.

const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
dracoLoader.setDecoderConfig({ type: 'js' });
const loader = new GLTFLoader(loadingManager);
loader.setDRACOLoader(dracoLoader);
let oldMaterial;
loader.load('/models/statue.glb', (gltf) => {
  gltf.scene.traverse((obj) => {
    if (obj.isMesh) {
      oldMaterial = obj.material;
      obj.material = new MeshPhongMaterial({ shininess: 100 });
    }
  });
  scene.add(gltf.scene);
  oldMaterial.dispose();
  renderer.renderLists.dispose();
});

Lighting

Add a directional light and a greenish point light that follows the mouse.

const directionLight = new DirectionalLight(0xffffff, 0.8);
directionLight.position.set(-100, 0, -100);
scene.add(directionLight);
const fillLight = new PointLight(0x88ffee, 2.7, 4, 3);
fillLight.position.set(30, 3, 1.8);
scene.add(fillLight);

Cursor & Mouse Interaction

A virtual cursor follows the real mouse pointer, and the point light moves in sync with the cursor position.

const cursor = { x:0, y:0 };
document.addEventListener('mousemove', event => {
  event.preventDefault();
  cursor.x = event.clientX / window.innerWidth - .5;
  cursor.y = event.clientY / window.innerHeight - .5;
  document.querySelector('.cursor').style.cssText = `left:${event.clientX}px;top:${event.clientY}px;`;
});

Animation Loop

The render loop updates the light position, camera group, and TWEEN animations based on mouse movement and the current page (detected via IntersectionObserver ).

let secondContainer = false;
const ob = new IntersectionObserver(payload => {
  secondContainer = payload[0].intersectionRatio > 0.05;
}, { threshold: 0.05 });
ob.observe(document.querySelector('.second'));
const clock = new Clock();
let previousTime = 0;
function tick(){
  const elapsed = clock.getElapsedTime();
  const delta = elapsed - previousTime;
  previousTime = elapsed;
  const parallaxY = cursor.y;
  const parallaxX = cursor.x;
  // Update point light
  fillLight.position.y -= (parallaxY * 9 + fillLight.position.y - 2) * delta;
  fillLight.position.x += (parallaxX * 8 - fillLight.position.x) * 2 * delta;
  // Update camera group
  cameraGroup.position.z -= (parallaxY/3 + cameraGroup.position.z) * 2 * delta;
  cameraGroup.position.x += (parallaxX/3 - cameraGroup.position.x) * 2 * delta;
  TWEEN.update();
  secondContainer ? renderer2.render(scene, camera2) : renderer.render(scene, camera);
  requestAnimationFrame(tick);
}
tick();

Navigation Hover Effects

When the mouse hovers over navigation items, the virtual cursor enlarges and follows the menu with a subtle offset.

const btn = document.querySelectorAll('nav > .a');
function update(e){
  const span = this.querySelector('span');
  if(e.type === 'mouseleave'){
    span.style.cssText = '';
  } else {
    const { offsetX:x, offsetY:y } = e;
    const { offsetWidth:w, offsetHeight:h } = this;
    const walk = 20;
    const xWalk = (x/w)*(walk*2)-walk;
    const yWalk = (y/h)*(walk*2)-walk;
    span.style.cssText = `transform: translate(${xWalk}px, ${yWalk}px);`;
  }
}
btn.forEach(b=>b.addEventListener('mousemove',update));
btn.forEach(b=>b.addEventListener('mouseleave',update));

Tab‑Controlled Camera Animation

Clicking a Tab on page 2 animates camera2 position and rotation using TWEEN, giving the impression that the model rotates.

function animateCamera(position, rotation){
  new TWEEN.Tween(camera2.position)
    .to(position, 1800)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .start()
    .onComplete(function(){ TWEEN.remove(this); });
  new TWEEN.Tween(camera2.rotation)
    .to(rotation, 1800)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .start()
    .onComplete(function(){ TWEEN.remove(this); });
}
// Example for first Tab
document.getElementById('one').addEventListener('click',()=>{
  document.getElementById('one').classList.add('active');
  document.getElementById('two').classList.remove('active');
  document.getElementById('three').classList.remove('active');
  document.getElementById('content').innerHTML = '昨夜西风凋碧树。独上高楼,望尽天涯路。';
  animateCamera({x:3.2, y:2.8, z:3.2}, {y:1});
});
// Similar handlers for 'two' and 'three' omitted for brevity.

Additional Knowledge

THREE.Clock provides getElapsedTime() and getDelta() for time‑based animations. IntersectionObserver detects which page section is currently visible to switch renderers.

Summary

The tutorial covers the following key points:

Compressing a model with Blender.

Using LoadingManager to track model loading.

Loading compressed models with DRACOLoader and GLTFLoader .

Setting outputEncoding for realistic rendering.

Animating positions and camera movements with TWEEN .

Illuminating the model with directional and point lights.

Creating a custom virtual cursor.

Animating the point light based on mouse movement.

Hover animations for navigation items.

Tab‑controlled model rotation via camera animation.

Basic usage of THREE.Clock .

Detecting element visibility with IntersectionObserver .

Adding custom fonts via @font-face .

Source code: https://github.com/dragonir/threejs-odessey

frontendThree.jsWebGL3DDRACOLoaderTween.js
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.