Skip to content
Learni
View all tutorials
JavaScript

How to Create an Animated 3D Scene with Three.js in 2026

Lire en français

Introduction

Three.js is the essential JavaScript library for handling WebGL without its native complexities. In 2026, it's still the top choice for web developers integrating interactive 3D renders, immersive data visualizations, or lightweight AR/VR experiences. This intermediate tutorial walks you through creating an animated 3D scene step-by-step: from basic setup to a textured rotating sphere with orbital controls and dynamic shadows.

Why it matters: E-commerce sites, portfolios, and dashboards increasingly use 3D to boost engagement (up to +40% time on page per A/B studies). You'll learn performance optimization (stable 60 FPS), mouse event handling, and integration with frameworks like React. By the end, you'll have production-ready, copy-paste code avoiding common issues like memory leaks or rendering artifacts. Ready to turn your HTML pages into 3D worlds?

Prerequisites

  • Basic knowledge of HTML, CSS, and JavaScript (ES6+).
  • Modern browser (Chrome 120+, Firefox 130+).
  • Code editor (VS Code recommended).
  • No installation needed: we use the Three.js r169 CDN (2026 stable version).

Basic Scene Setup

index.html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js Scène de Base</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; }
        canvas { display: block; }
    </style>
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.169.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
            }
        }
    </script>
    <script type="module">
        import * as THREE from 'three';

        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);

        camera.position.z = 5;

        function animate() {
            requestAnimationFrame(animate);
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

This code sets up an empty scene with a perspective camera, WebGL renderer, and 60 FPS animation loop. The ES modules importmap simplifies CDN usage without a bundler. The resize handler prevents distortions on window resize; test by opening the file in a browser.

Understanding the Render Loop

The requestAnimationFrame loop syncs rendering with the screen refresh rate (unlike setInterval, which wastes CPU). Think of it as a car engine running continuously for smoothness. The scene is the container, the camera the viewpoint, and the renderer the projector. Next: add a mesh to see something.

Adding a Cube with Basic Material

index-cube.html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js Cube</title>
    <style> body { margin: 0; overflow: hidden; background: #000; } canvas { display: block; } </style>
</head>
<body>
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.169.0/build/three.module.js" } }
    </script>
    <script type="module">
        import * as THREE from 'three';

        const scene = new THREE.Scene();
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        document.body.appendChild(renderer.domElement);

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
        const cube = new THREE.Mesh(geometry, material);
        scene.add(cube);

        camera.position.z = 5;

        function animate() {
            requestAnimationFrame(animate);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

We add a cube using BoxGeometry and MeshBasicMaterial (unaffected by lights). Rotation in the animate() function creates a smooth spinning effect. Pitfall: without scene.add(), the mesh is invisible; BasicMaterial is great for debugging as it's fast.

Materials and Geometries

Geometries define the shape (like a mold), materials the surface (color, reflection). MeshBasicMaterial ignores lights for simplicity. Next: realistic lighting with MeshStandardMaterial for PBR (Physically Based Rendering) effects.

Adding Lights and Standard Material

index-lights.html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js Lumières</title>
    <style> body { margin: 0; overflow: hidden; background: #000; } canvas { display: block; } </style>
</head>
<body>
    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.169.0/build/three.module.js" } }
    </script>
    <script type="module">
        import * as THREE from 'three';

        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x222222);
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshStandardMaterial({ color: 0xff6347 });
        const cube = new THREE.Mesh(geometry, material);
        cube.castShadow = true;
        cube.position.y = 0.5;
        scene.add(cube);

        const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
        scene.add(ambientLight);

        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
        directionalLight.position.set(1, 1, 0.5);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        scene.add(directionalLight);

        camera.position.z = 5;

        function animate() {
            requestAnimationFrame(animate);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

MeshStandardMaterial responds to lights for realistic rendering. AmbientLight provides uniform illumination, DirectionalLight simulates sunlight with soft shadows (PCFSoftShadowMap). Enable shadowMap to avoid artifacts; increase mapSize for sharper shadows without killing performance.

Adding OrbitControls for Interaction

index-controls.html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js OrbitControls</title>
    <style> body { margin: 0; overflow: hidden; background: #000; } canvas { display: block; } </style>
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.169.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
            }
        }
    </script>
    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x222222);
        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshStandardMaterial({ color: 0xff6347 });
        const cube = new THREE.Mesh(geometry, material);
        cube.castShadow = true;
        cube.position.y = 0.5;
        scene.add(cube);

        const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
        scene.add(ambientLight);

        const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
        directionalLight.position.set(1, 1, 0.5);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        scene.add(directionalLight);

        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.05;
        camera.position.z = 5;

        function animate() {
            requestAnimationFrame(animate);
            cube.rotation.x += 0.01;
            cube.rotation.y += 0.01;
            controls.update();
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

OrbitControls enables mouse zoom, pan, and rotation (standard 3D UX). enableDamping adds smooth inertia like in Blender. Call controls.update() in the loop; otherwise, controls lag, especially on mobile.

Building a Textured Animated Sphere

Next level: Replace the cube with a textured sphere (like a planet). Add a ground plane for shadows and more sophisticated animations.

Complete Scene with Textured Sphere and Ground

index-complete.html
<!DOCTYPE html>
<html lang="fr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Three.js Scène Complète Animée</title>
    <style> body { margin: 0; overflow: hidden; background: #000; } canvas { display: block; } </style>
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.169.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.169.0/examples/jsm/"
            }
        }
    </script>
    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0x87CEEB);
        scene.fog = new THREE.Fog(0x87CEEB, 10, 100);

        const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        document.body.appendChild(renderer.domElement);

        // Sol
        const groundGeometry = new THREE.PlaneGeometry(20, 20);
        const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x90EE90 });
        const ground = new THREE.Mesh(groundGeometry, groundMaterial);
        ground.rotation.x = -Math.PI / 2;
        ground.position.y = -1;
        ground.receiveShadow = true;
        scene.add(ground);

        // Sphère texturée
        const textureLoader = new THREE.TextureLoader();
        const sphereGeometry = new THREE.SphereGeometry(0.8, 32, 32);
        const sphereMaterial = new THREE.MeshStandardMaterial({
            map: textureLoader.load('https://threejs.org/examples/textures/uv_grid_opengl.jpg'),
            metalness: 0.1,
            roughness: 0.5
        });
        const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
        sphere.position.y = 0.5;
        sphere.castShadow = true;
        scene.add(sphere);

        // Lumières
        const ambientLight = new THREE.AmbientLight(0x404040, 0.4);
        scene.add(ambientLight);

        const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
        directionalLight.position.set(5, 10, 5);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        directionalLight.shadow.camera.near = 0.5;
        directionalLight.shadow.camera.far = 50;
        directionalLight.shadow.camera.left = -10;
        directionalLight.shadow.camera.right = 10;
        directionalLight.shadow.camera.top = 10;
        directionalLight.shadow.camera.bottom = -10;
        scene.add(directionalLight);

        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.05;
        camera.position.set(0, 2, 5);

        const clock = new THREE.Clock();

        function animate() {
            requestAnimationFrame(animate);
            const elapsedTime = clock.getElapsedTime();
            sphere.rotation.y = elapsedTime * 0.5;
            sphere.position.x = Math.sin(elapsedTime * 0.3) * 2;
            controls.update();
            renderer.render(scene, camera);
        }
        animate();

        window.addEventListener('resize', () => {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
        });
    </script>
</body>
</html>

Final scene: textured sphere orbiting over a shadowed ground plane, with fog for depth. TextureLoader loads a UV grid (replace with your image). Clock enables smooth time-based animations; tweak shadow.camera for precise shadows without clipping.

Best Practices

  • Optimize geometries: Use BufferGeometryUtils.mergeBufferGeometries() for batching.
  • Dispose objects: Call geometry.dispose(), material.dispose(), and texture.dispose() to prevent memory leaks.
  • Limit draw calls: Instance meshes with InstancedMesh for 1000+ objects.
  • Performance monitoring: Add Stats.js and check renderer.info for FPS/debug.
  • Mobile-first: Use renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)).

Common Mistakes to Avoid

  • Forgetting controls.update(): Controls freeze after pauses.
  • Static camera: Always use camera.lookAt(0,0,0) or OrbitControls.
  • Incorrect textures: Set texture.encoding = THREE.sRGBEncoding for accurate colors.
  • Infinite loops without RAF: Always use requestAnimationFrame over timeouts.

Next Steps

Integrate Three.js with React using @react-three/fiber or Next.js. Explore GLTF loaders for complex 3D models. Discover our Learni courses on advanced WebGL and custom shaders. Official docs: threejs.org.