Skip to content
Learni
View all tutorials
JavaScript

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

Lire en français

Introduction

Three.js is the go-to JavaScript library for harnessing WebGL without getting bogged down in its native complexities. In 2026, with GPU hardware advancements and optimized browsers, it delivers immersive 3D experiences right in the browser—from data visualizations to lightweight games.

This intermediate tutorial guides you step by step to build a complete interactive 3D scene: basic setup, meshes with geometry, realistic lighting, fluid animations, orbit controls, and click interactions via raycasting. Each step comes with standalone, functional HTML code using the official Three.js CDN for instant implementation.

Why does it matter? E-commerce sites, portfolios, and dashboards now use 3D to increase user engagement by 40% (source: Google studies). By the end, you'll have a solid, bookmarkable foundation to expand into complex projects like GLTF models or custom shaders. Ready to make the web spatial? (142 words)

Prerequisites

  • Solid JavaScript ES6+ knowledge and DOM manipulation.
  • Modern browser (Chrome 120+, Firefox 120+).
  • Code editor (VS Code recommended).
  • No server setup needed: everything via CDN and local HTML file.

Basic Setup: Renderer and Animation Loop

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 Basique</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, antialias-enabled WebGL renderer, and an animation loop using requestAnimationFrame. The resize handler keeps proportions intact. Tip: Always use an importmap for ES6 modules via CDN to avoid module resolution errors.

Adding a Mesh: Your First 3D Object

Now, let's add a cube as our first mesh. A mesh combines geometry (shape) and material (appearance). Think of geometry as the skeleton and material as the skin.

Adding a Cube with Basic Material

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 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",
                "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);

        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 create a BoxGeometry and MeshBasicMaterial (unaffected by lights). The cube is added to the scene and rotates gently in the animate loop. Common pitfall: Forgetting scene.add() makes the object invisible; BasicMaterial is perfect for quick tests without lights.

Realistic Lighting

MeshBasicMaterial ignores lights. Switch to MeshStandardMaterial with lights for physically based rendering. Analogy: Like upgrading from a bare bulb to a directional spotlight.

Adding Lights and Standard Material

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 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",
                "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();
        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: 0x00ff00 });
        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, 1);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        scene.add(directionalLight);

        const planeGeometry = new THREE.PlaneGeometry(5, 5);
        const planeMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
        const plane = new THREE.Mesh(planeGeometry, planeMaterial);
        plane.rotation.x = -Math.PI / 2;
        plane.receiveShadow = true;
        scene.add(plane);

        camera.position.set(0, 2, 5);
        camera.lookAt(0, 0, 0);

        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>

AmbientLight provides soft global illumination, DirectionalLight casts realistic shadows (enable shadowMap). MeshStandardMaterial responds to lights; add a plane to receive shadows. Pitfall: No shadows without shadowMap.enabled; increase mapSize for sharper shadows on large screens.

Interactive Controls with OrbitControls

To manipulate the view, integrate OrbitControls for intuitive rotation, zoom, and pan.

Integrating OrbitControls

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 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 controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.05;

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
        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, 1);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        scene.add(directionalLight);

        const planeGeometry = new THREE.PlaneGeometry(5, 5);
        const planeMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
        const plane = new THREE.Mesh(planeGeometry, planeMaterial);
        plane.rotation.x = -Math.PI / 2;
        plane.receiveShadow = true;
        scene.add(plane);

        camera.position.set(0, 2, 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);
            controls.update();
        });
    </script>
</body>
</html>

Import OrbitControls and link it to the camera/renderer. enableDamping adds inertia. Call controls.update() in the animate loop. Pitfall: Skip update() on resize and controls glitch; dampingFactor over 0.1 feels sluggish on mobile.

Advanced Interactions: Raycasting for Clicks

Make the scene reactive: Detect clicks on the cube to change its color.

Raycasting for Object Selection

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 Raycasting</title>
    <style>
        body { margin: 0; overflow: hidden; background: #000; }
        canvas { display: block; cursor: pointer; }
    </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 controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.05;

        const raycaster = new THREE.Raycaster();
        const mouse = new THREE.Vector2();

        const geometry = new THREE.BoxGeometry(1, 1, 1);
        const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
        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, 1);
        directionalLight.castShadow = true;
        directionalLight.shadow.mapSize.width = 2048;
        directionalLight.shadow.mapSize.height = 2048;
        scene.add(directionalLight);

        const planeGeometry = new THREE.PlaneGeometry(5, 5);
        const planeMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa });
        const plane = new THREE.Mesh(planeGeometry, planeMaterial);
        plane.rotation.x = -Math.PI / 2;
        plane.receiveShadow = true;
        scene.add(plane);

        camera.position.set(0, 2, 5);

        function onMouseClick(event) {
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects([cube]);

            if (intersects.length > 0) {
                material.color.setHex(Math.random() * 0xffffff);
            }
        }

        window.addEventListener('click', onMouseClick);

        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);
            controls.update();
        });
    </script>
</body>
</html>

Raycaster shoots a ray from the mouse through the camera; intersectObjects targets the cube. On click, it randomly changes the color. Pitfall: Normalize mouse coords correctly (-1 to +1); pass an array of objects to scale to multiple meshes.

Performance Optimizations

Best Practices

  • Enable renderer.toneMapping = THREE.ACESFilmicToneMapping for HDR-like rendering.
  • Use InstancedMesh for 1000+ identical objects (GPU savings).
  • Dispose unused geometries/materials: geometry.dispose(), material.dispose().
  • Limit draw calls: Merge meshes where possible.
  • Test FPS with Stats.js; aim for 60 FPS on mobile.

Common Errors to Avoid

  • Forgetting dispose(): Memory leaks on long sessions → crashes.
  • Static camera without controls: Frozen scene, poor UX.
  • Shadows without bounds: Visual artifacts; set shadow.cameraLeft/Right.
  • Suboptimal near/far: Clipping objects or Z-fighting.

Next Steps

Dive deeper with GLTF loaders (pro 3D models), post-processing (bloom, FXAA), and custom shaders via ShaderMaterial.

Resources:


Check out our Learni WebGL and 3D courses to master VR/AR with Three.js.