HOW TO PROGRAM A
3D MENU IN THREE.JS
By Shane Brumback · · Free Three.js tutorial
A 3D menu in Three.js is a ring of textured image panels placed evenly around a circle, all facing the center, that you orbit with the mouse and click to navigate. A Raycaster turns a click on a panel into a page jump, and a slow auto-spin keeps it alive. This free tutorial builds the whole thing in one copy-paste file.
Instead of a flat list of links, your navigation becomes a carousel you can look around — drag to orbit, click a panel to go there. Click and drag the demo behind this page, then copy the code and make it your own.
LIVE DEMO
The slowly spinning scene behind this page is the live demo. Press the button to go full screen — the page UI hides so you can click and drag to orbit the menu and click a panel to open it.
Hides the page so you get the scene full screen — drag to orbit, click a panel, press Esc to exit.
Introduction
A flat navigation bar tells people where they can go. A 3D menu lets them feel it — the links become panels floating in space that you can orbit around and click to travel. It is one of the fastest ways to make a site feel like an experience instead of a page, and the surprising part is how little code it takes.
The trick is simple geometry. Each menu item is a flat PlaneGeometry with an image painted on it. We place those planes evenly around a circle so they form a ring, turn each one to face the middle, and drop the camera inside. OrbitControls lets the visitor look around, a Raycaster figures out which panel they clicked, and a slow spin on the whole scene keeps it from feeling static.
What you should know first: this is a beginner-friendly build. If you have ever made a Three.js scene with a renderer, a camera, and a mesh in a render loop, you are ready. If that is brand new, skim Creating and Animating Simple 3D Objects first. No build tools — everything loads from a CDN.
Why 3D Menus? Read the Article
Before you build it, see why 3D interactive menus and websites grab attention: immersion, interactivity, WebGL realism and cross-platform reach. Read the article →
Frequently Asked Questions
How do I create a 3D menu using Three.js?
Make one textured plane for each menu item, place the planes evenly around a circle so they form a ring, and point each one at the center. Add OrbitControls so the user can look around the ring, and a Raycaster so a click on a panel reads its stored link and navigates there. A slow rotation on the scene gives it life. In Three.js that is a TextureLoader, a PlaneGeometry per item, OrbitControls, and a Raycaster — no framework needed.
How do I detect clicks on 3D objects for menu interaction?
Convert the mouse position to normalized device coordinates (-1 to 1 on each axis), call raycaster.setFromCamera(mouse, camera) to shoot a ray into the scene, then raycaster.intersectObjects(panels). The first intersection is the panel the user clicked, so you read its userData and act on it. Listen on mousedown or pointerdown.
How do I put images on 3D menu items in Three.js?
Load each image with THREE.TextureLoader().load(url), set texture.colorSpace = THREE.SRGBColorSpace so colors look right, and apply it as the map of a MeshBasicMaterial on a PlaneGeometry. MeshBasicMaterial ignores lighting, so the panels show the image at full brightness like a screen.
How do I arrange menu items in a ring in Three.js?
Split a full circle into equal slices (angle = 2*Math.PI / itemCount), then for item i set x = radius * Math.cos(angle*i) and z = radius * Math.sin(angle*i), leaving y at 0. Call plane.lookAt(camera.position) so every panel faces inward. The result is an evenly spaced carousel you can orbit around.
How do I add 3D models like trees around a Three.js menu?
Load a GLB with GLTFLoader (add MeshoptDecoder if the file is meshopt-compressed), clone the tree node you want for each copy, then scale, rotate and place each clone outside the ring of panels so nothing blocks the menu. This demo reuses the pine trees from the Super Soldier Battle game.
Is this Three.js 3D menu tutorial free?
Yes. The full tutorial and code are free on this page — copy it or press Launch to run it in a new tab — and the small spinning preview behind the page runs with no build tools. The full-screen live demo just asks for a quick free Google sign-in.
How It Works
- A data list — A data list of the latest tutorials (image + link pairs, newest first) defines the menu, so adding or removing a panel is just editing an array.
- A bright sky — A scene, camera and renderer with a hemisphere light, a shadow-casting sun and a gradient sky dome with a sun glow give the menu a bright, happy backdrop.
- Still grass — Fanned grass-blade clumps in two layers (tall field grass and short bright tufts) drawn with InstancedMesh, a two-tone lit shader and a matching depth material so the grass casts shadows. The wind is turned off, so the field stays still.
- Natural clouds and pine trees — Soft sprite clouds painted from radial gradients on a small canvas float high in the sky and turn slowly, and twelve pine trees from the Super Soldier Battle game (loaded with GLTFLoader) stand outside the ring of panels.
- A ring of panels — Each image is loaded with TextureLoader onto a PlaneGeometry placed around a circle with sine and cosine, facing the center, with a hidden glow frame behind it.
- Hover, click and tap — OrbitControls orbit the ring; a Raycaster on pointer move grows and highlights the hovered panel, and a click or tap opens its link.
- Render loop + resize — A render loop turns the clouds, eases the hover scale, and a resize handler keeps it sharp.
Code Breakdown
Here is the whole menu in plain English. Beginners: read the bold summaries. Developers: the gray notes after each explain the why and the gotchas.
1. The menu as data: the latest tutorials
// 1) The menu as data: the latest tutorials, newest first (image + link).
const SITE = 'https://www.shanebrumback.com';
const jsonData = [
{ image: SITE + '/images/threejs-xbox-controller-character-animation-card.jpg?v=20261102', link: SITE + '/tutorials/threejs-xbox-controller-character-animation.html' },
{ image: SITE + '/images/educational-space-game-tutorial-card.jpg?v=20261103', link: SITE + '/tutorials/threejs-solar-system-educational-game-tutorial.html' },
{ image: SITE + '/images/blender-to-threejs-game-model-tutorial.jpg', link: SITE + '/articles/creating-3d-games-with-blender-and-threejs.html' },
{ image: SITE + '/images/threejs-youtube-video-texture.jpg', link: SITE + '/tutorials/threejs-youtube-video-texture.html' },
{ image: SITE + '/images/threejs-rocket-engine-particle-system-hero.jpg', link: SITE + '/threejs-particle-system-rocket-engine.html' },
{ image: SITE + '/images/threejs-cas-sharpen-pass-tutorial-2026.jpg', link: SITE + '/tutorials/threejs-cas-sharpen-pass-tutorial.html' },
{ image: SITE + '/images/first-person-shooter-game-starter-2026.jpg?v=20261030', link: SITE + '/tutorials/first-person-shooter-game-starter.html' },
{ image: SITE + '/images/threejs-fps-controls-pointerlockcontrols-tutorial-2026.jpg?v=20261102c', link: SITE + '/tutorials/threejs-fps-controls-pointerlockcontrols-tutorial.html' },
{ image: SITE + '/images/first-person-shooter-game-tommy-gun-starter-code-2026.jpg?v=20261102c', link: SITE + '/tutorials/first-person-shooter-game-tommy-gun-starter-code.html' },
{ image: SITE + '/images/simple-particle-system-2026.jpg', link: SITE + '/tutorials/simple-particle-system.html' },
{ image: SITE + '/images/threejs-gerstner-waves-buoyancy.webp', link: SITE + '/tutorials/threejs-gerstner-waves-buoyancy.html' },
{ image: SITE + '/images/threejs-terrain-lod-splat-shader-grass.png', link: SITE + '/tutorials/threejs-terrain-lod-splat-shader-grass.html' }
];
In plain English: the menu is a list of the newest tutorials on this site, newest first — each entry is a card picture and the page it opens. Want another panel? Add another line. Developer note: the URLs are absolute (https://www.shanebrumback.com) so the code works when you copy it into your own file, and the ring layout below adapts to any number of items.
2. Scene, camera and a bright, happy sky
// 2) Scene, camera, renderer + a bright happy gradient sky with a sun glow
// (same sky dome as the site's animated cube tutorial).
const isMobile = window.matchMedia('(pointer: coarse)').matches;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xcfe6f8, 30, 120);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 3, 0.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const sunDir = new THREE.Vector3().setFromSphericalCoords(1, THREE.MathUtils.degToRad(55), THREE.MathUtils.degToRad(35));
const sky = new THREE.Mesh(
new THREE.SphereGeometry(900, 32, 16),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
fog: false,
uniforms: {
uTop: { value: new THREE.Color(0x2f86e8) },
uHorizon: { value: new THREE.Color(0xcfe6f8) },
uSunDir: { value: sunDir }
},
vertexShader: `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uTop; uniform vec3 uHorizon; uniform vec3 uSunDir;
varying vec3 vDir;
void main() {
float h = max(vDir.y, 0.0);
vec3 col = mix(uHorizon, uTop, pow(h, 0.55));
float sun = max(dot(normalize(vDir), uSunDir), 0.0);
col += vec3(1.0, 0.92, 0.75) * (pow(sun, 600.0) * 2.0 + pow(sun, 12.0) * 0.25);
gl_FragColor = vec4(col, 1.0);
}`,
toneMapped: false
})
);
scene.add(sky);
scene.add(new THREE.HemisphereLight(0xbfe3ff, 0x1a3a10, 0.9));
const sun = new THREE.DirectionalLight(0xfff2d6, 1.6);
sun.position.set(-12, 16, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 1; sun.shadow.camera.far = 80;
sun.shadow.camera.left = -40; sun.shadow.camera.right = 40;
sun.shadow.camera.top = 40; sun.shadow.camera.bottom = -40;
sun.shadow.bias = -0.0004;
scene.add(sun);
In plain English: the usual scene, camera and renderer, plus a giant sphere around everything painted blue at the top and pale near the horizon, with a bright glow where the sun is, so the world feels like a sunny day. Developer note: the sky is a ShaderMaterial on a back-faced sphere, so it costs one draw call and no texture; light fog fades the far grass into the horizon.
3. Still grass from the endless grass tutorial
// 3) Grass from the Endless Procedural Grass Field tutorial: fanned blade
// geometry, the same two-tone lit shader and a matching depth material for
// shadows. The wind is switched off, so the field stays perfectly still.
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(400, 400),
new THREE.MeshStandardMaterial({ color: 0x1a3a10, roughness: 0.95, metalness: 0.0 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
function makeBladeGeo(bladeWidth, bladeHeight, bladeMidHeight, spread) {
const numBlades = 4, verts = [], uvs = [];
for (let b = 0; b < numBlades; b++) {
const a = (b / numBlades) * Math.PI * 2, cos = Math.cos(a), sin = Math.sin(a);
const midX = cos * spread * 0.43, midZ = sin * spread * 0.43;
const tipX = cos * spread, tipZ = sin * spread;
verts.push(-bladeWidth/2*cos,0,-bladeWidth/2*sin, bladeWidth/2*cos,0,bladeWidth/2*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin);
verts.push(-bladeWidth/2*cos,0,-bladeWidth/2*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin, midX-bladeWidth/4*cos,bladeMidHeight,midZ-bladeWidth/4*sin);
verts.push(midX-bladeWidth/4*cos,bladeMidHeight,midZ-bladeWidth/4*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin, tipX,bladeHeight,tipZ);
uvs.push(0,0,1,0,1,0.33, 0,0,1,0.33,0,0.33, 0,0.33,1,0.33,0.5,1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
function makeGrassMaterials(baseHex, tipHex) {
const mat = new THREE.ShaderMaterial({
uniforms: { uBase: { value: new THREE.Color(baseHex) }, uTip: { value: new THREE.Color(tipHex) },
uSunDir: { value: new THREE.Vector3(-12, 16, 6).normalize() }, uSunColor: { value: new THREE.Color(0xfff2d6) } },
vertexShader: `
varying vec2 vUv; varying vec3 vNormal;
void main() {
vUv = uv;
vNormal = normalize(mat3(instanceMatrix) * normal);
gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uBase; uniform vec3 uTip; uniform vec3 uSunDir; uniform vec3 uSunColor;
varying vec2 vUv; varying vec3 vNormal;
void main() {
vec3 col = mix(uBase, uTip, vUv.y);
float diff = abs(dot(normalize(vNormal), uSunDir));
float ao = mix(0.55, 1.0, vUv.y);
vec3 lit = col * ao * (0.5 + 0.9 * diff) + uSunColor * diff * 0.12 * vUv.y;
gl_FragColor = vec4(lit, 1.0);
}`,
side: THREE.DoubleSide
});
const depth = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vHighPrec;
void main() {
vec4 mvPos = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPos;
vHighPrec = gl_Position.zw;
}`,
fragmentShader: `
#include <packing>
varying vec2 vHighPrec;
void main() { gl_FragColor = packDepthToRGBA(0.5 * vHighPrec.x / vHighPrec.y + 0.5); }`,
side: THREE.DoubleSide
});
return { mat, depth };
}
// A round patch of radius GRASS_R around the menu; fog fades the rim into the sky.
const GRASS_R = 30;
function makeLayer(geo, matPair, count, sMin, sMax) {
const mesh = new THREE.InstancedMesh(geo, matPair.mat, count);
mesh.castShadow = true; mesh.customDepthMaterial = matPair.depth; mesh.frustumCulled = false;
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
const r = Math.sqrt(Math.random()) * GRASS_R, a = Math.random() * Math.PI * 2, s = sMin + Math.random() * (sMax - sMin);
dummy.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
dummy.rotation.set(0, Math.random() * Math.PI * 2, 0);
dummy.scale.set(s, s * (0.85 + Math.random() * 0.3), s);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
scene.add(mesh);
return mesh;
}
// Type A: tall thin field grass. Type B: short wide bright tufts.
const density = isMobile ? 0.5 : 1;
makeLayer(makeBladeGeo(0.16, 1.8, 0.6, 0.35), makeGrassMaterials(0x1a4010, 0x4a9030), Math.round(24000 * density), 0.8, 1.3);
makeLayer(makeBladeGeo(0.26, 0.7, 0.3, 0.5), makeGrassMaterials(0x2b5a16, 0x8fc24a), Math.round(16000 * density), 0.9, 1.5);
In plain English: this is the same grass as the Endless Procedural Grass Field tutorial: each instance is a little clump of four fanned blades, in a tall layer and a short bright tuft layer, scattered in a round patch around the menu. The blades are shaded darker at the root and brighter at the tip, cast soft shadows, and stay still (no wind sway). Developer note: one InstancedMesh draws every blade in a single draw call, and a customDepthMaterial lets the instanced blades cast shadows. The original tutorial adds a wind function to both shaders; it is left out here so the grass is static. Mobile gets half the clumps.
4. Soft, natural clouds and SSB pine trees
// 4) Natural soft sprite clouds (same approach as the animated cube tutorial).
function makeCloudTexture() {
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d');
// Every puff stays fully inside the canvas (radius + position leave padding on
// all four sides), so each one fades to transparent before the edge: soft and
// puffy all round, no clipped straight edges.
for (let i = 0; i < 9; i++) {
const x = 80 + Math.random() * 96, y = 112 + Math.random() * 32, r = 30 + Math.random() * 30;
const grad = g.createRadialGradient(x, y, 0, x, y, r);
grad.addColorStop(0, 'rgba(255,255,255,0.9)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grad;
g.fillRect(0, 0, 256, 256);
}
return new THREE.CanvasTexture(c);
}
const clouds = new THREE.Group();
const cloudTex = makeCloudTexture();
for (let i = 0; i < 16; i++) {
const cloud = new THREE.Sprite(new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.85, depthWrite: false, fog: false, toneMapped: false }));
const a = Math.random() * Math.PI * 2, d = 180 + Math.random() * 200;
cloud.position.set(Math.cos(a) * d, 45 + Math.random() * 70, Math.sin(a) * d);
cloud.scale.set(90 + Math.random() * 80, 45 + Math.random() * 30, 1);
clouds.add(cloud);
}
scene.add(clouds);
// 4b) Pine trees from Super Soldier Battle (the game's pine_better.glb, tree_A/B/C_LOD0).
// Each pine is authored lying along +Z, so it is stood upright inside a pivot
// group, then scaled, turned and placed. They stand well outside the ring of
// panels (radius 34 to 46) in the gaps between panels, so the view from the
// centre stays clear and they peek up over the menu like a forest edge.
function pineMaterial(src) {
const foliage = src.side === THREE.DoubleSide || src.alphaTest > 0 || src.transparent;
return new THREE.MeshLambertMaterial({ map: src.map || null, alphaTest: foliage ? 0.3 : 0, side: src.side });
}
const treeSpots = [
{ a: 15, r: 36, s: 0.17, v: 'A' }, { a: 48, r: 42, s: 0.20, v: 'C' },
{ a: 78, r: 34, s: 0.15, v: 'B' }, { a: 105, r: 44, s: 0.21, v: 'A' },
{ a: 135, r: 38, s: 0.17, v: 'C' }, { a: 165, r: 46, s: 0.22, v: 'B' },
{ a: 196, r: 35, s: 0.16, v: 'A' }, { a: 228, r: 41, s: 0.19, v: 'C' },
{ a: 255, r: 37, s: 0.18, v: 'B' }, { a: 287, r: 45, s: 0.21, v: 'A' },
{ a: 316, r: 34, s: 0.15, v: 'C' }, { a: 345, r: 40, s: 0.19, v: 'B' }
];
const gltfLoader = new GLTFLoader();
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
gltfLoader.load('https://www.shanebrumback.com/models/glb/ssb-pine-trees.glb', function (gltf) {
treeSpots.forEach(function (spot, i) {
const proto = gltf.scene.getObjectByName('tree_' + spot.v + '_LOD0') || gltf.scene.getObjectByName('tree_A_LOD0');
const tree = proto.clone(true);
tree.traverse(function (child) {
if (child.isMesh) { child.material = pineMaterial(child.material); child.castShadow = true; }
});
tree.rotation.set(-Math.PI / 2, 0, 0); // stand it upright (+Z becomes +Y)
const pivot = new THREE.Group();
pivot.add(tree);
pivot.scale.setScalar(spot.s);
pivot.rotation.y = i * 2.4;
const ang = THREE.MathUtils.degToRad(spot.a);
pivot.position.set(Math.cos(ang) * spot.r, 0, Math.sin(ang) * spot.r);
pivot.updateMatrixWorld(true);
pivot.position.y -= new THREE.Box3().setFromObject(pivot).min.y; // base on the ground
scene.add(pivot);
});
});
In plain English: we paint a few fuzzy white blobs on a 256 pixel canvas, keeping every blob well inside its edges, and hang copies of it far away in the sky, the same way the animated cube tutorial does. The whole group turns very slowly. Developer note: every cloud is a Sprite sharing one CanvasTexture, so it always faces the camera and costs almost nothing. The pines are the game's own pine_better.glb (meshopt-compressed, so the loader gets MeshoptDecoder), stood upright inside a pivot group and placed at radius 34 to 46 in the gaps between panels so they never block the menu.
5. Build the ring of image panels
// 5) The ring of image panels, each with a glow frame for the hover highlight.
const loader = new THREE.TextureLoader();
const radius = 14;
const step = (2 * Math.PI) / jsonData.length;
const panels = [];
jsonData.forEach(function (item, i) {
const texture = loader.load(item.image);
texture.colorSpace = THREE.SRGBColorSpace;
const group = new THREE.Group();
group.position.set(radius * Math.cos(step * i), 3.4, radius * Math.sin(step * i));
group.lookAt(0, 3.4, 0);
const glow = new THREE.Mesh(new THREE.PlaneGeometry(6.5, 3.55), new THREE.MeshBasicMaterial({ color: 0xffd84d, transparent: true, opacity: 0, fog: false }));
glow.position.z = -0.02;
const plane = new THREE.Mesh(new THREE.PlaneGeometry(6, 3.15), new THREE.MeshBasicMaterial({ map: texture, fog: false }));
plane.userData = { link: item.link, group: group, glow: glow, hover: 0 };
group.add(glow, plane);
scene.add(group);
panels.push(plane);
});
In plain English: for each tutorial we paint its card onto a flat panel, place it around a circle, turn it to face the middle and hide a yellow frame behind it for the hover highlight. Developer note: MeshBasicMaterial keeps the cards at full brightness, and the 6 × 3.15 plane matches the 1200 × 630 card ratio so nothing stretches. The panel and frame live in a group so they scale together.
6. Orbit, hover to grow, click or tap to open
// 6) Orbit from the middle of the ring, clamped near eye level.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 3, 0);
controls.minPolarAngle = THREE.MathUtils.degToRad(80);
controls.maxPolarAngle = THREE.MathUtils.degToRad(100);
controls.rotateSpeed = 0.35;
controls.enableZoom = false;
controls.enablePan = false;
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
// 7) Hover to grow + highlight a panel, click or tap to open it.
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null, downX = 0, downY = 0;
function pick(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(panels, false);
return hits.length ? hits[0].object : null;
}
renderer.domElement.addEventListener('pointermove', function (event) {
if (event.pointerType !== 'mouse') return;
hovered = pick(event);
renderer.domElement.style.cursor = hovered ? 'pointer' : 'grab';
controls.autoRotate = !hovered; // pause the spin while aiming
});
renderer.domElement.addEventListener('pointerleave', function () { hovered = null; controls.autoRotate = true; });
renderer.domElement.addEventListener('pointerdown', function (event) { downX = event.clientX; downY = event.clientY; });
renderer.domElement.addEventListener('pointerup', function (event) {
if (Math.hypot(event.clientX - downX, event.clientY - downY) > 8) return; // it was a drag
const hit = pick(event);
if (!hit) return;
hovered = hit; // pop the tapped panel, then go
setTimeout(function () { window.location.href = hit.userData.link; }, event.pointerType === 'mouse' ? 0 : 180);
});
In plain English: drag to look around; when the mouse rests on a panel it grows a little and lights up, and a click (or a tap on a phone) takes you to that tutorial. Developer note: hover only runs for pointerType === 'mouse' pointers, a drag longer than 8px is not treated as a click, and taps pop the panel for a moment before navigating, so touch screens work without hover.
7. The render loop and resize
// 8) Render loop: slowly turning clouds, smooth hover scale, resize.
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1), t = clock.elapsedTime;
clouds.rotation.y += dt * 0.004;
panels.forEach(function (p) {
const u = p.userData;
u.hover += ((p === hovered ? 1 : 0) - u.hover) * Math.min(1, dt * 12);
u.group.scale.setScalar(1 + 0.12 * u.hover);
u.glow.material.opacity = 0.9 * u.hover;
});
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
In plain English: every frame we turn the clouds a touch, ease each panel toward its hover size (1.12×) and draw. Resizing the window keeps everything in proportion. Developer note: easing hover by a fraction of the frame time gives a smooth grow and shrink instead of a snap, and the pixel ratio is capped at 2 to keep phones fast.
The Full Code
Copy this into an .html file and open it, or press Launch to run it in a new tab right now. Swap the images and links in jsonData for your own. Hover a panel to see it grow, click or tap to open it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Three.js Interactive 3D Menu</title>
<style> body { margin: 0; overflow: hidden; } canvas { display: block; touch-action: none; } </style>
<script async src="https://unpkg.com/es-module-shims@1.8.0/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
// 1) The menu as data: the latest tutorials, newest first (image + link).
const SITE = 'https://www.shanebrumback.com';
const jsonData = [
{ image: SITE + '/images/threejs-xbox-controller-character-animation-card.jpg?v=20261102', link: SITE + '/tutorials/threejs-xbox-controller-character-animation.html' },
{ image: SITE + '/images/educational-space-game-tutorial-card.jpg?v=20261103', link: SITE + '/tutorials/threejs-solar-system-educational-game-tutorial.html' },
{ image: SITE + '/images/blender-to-threejs-game-model-tutorial.jpg', link: SITE + '/articles/creating-3d-games-with-blender-and-threejs.html' },
{ image: SITE + '/images/threejs-youtube-video-texture.jpg', link: SITE + '/tutorials/threejs-youtube-video-texture.html' },
{ image: SITE + '/images/threejs-rocket-engine-particle-system-hero.jpg', link: SITE + '/threejs-particle-system-rocket-engine.html' },
{ image: SITE + '/images/threejs-cas-sharpen-pass-tutorial-2026.jpg', link: SITE + '/tutorials/threejs-cas-sharpen-pass-tutorial.html' },
{ image: SITE + '/images/first-person-shooter-game-starter-2026.jpg?v=20261030', link: SITE + '/tutorials/first-person-shooter-game-starter.html' },
{ image: SITE + '/images/threejs-fps-controls-pointerlockcontrols-tutorial-2026.jpg?v=20261102c', link: SITE + '/tutorials/threejs-fps-controls-pointerlockcontrols-tutorial.html' },
{ image: SITE + '/images/first-person-shooter-game-tommy-gun-starter-code-2026.jpg?v=20261102c', link: SITE + '/tutorials/first-person-shooter-game-tommy-gun-starter-code.html' },
{ image: SITE + '/images/simple-particle-system-2026.jpg', link: SITE + '/tutorials/simple-particle-system.html' },
{ image: SITE + '/images/threejs-gerstner-waves-buoyancy.webp', link: SITE + '/tutorials/threejs-gerstner-waves-buoyancy.html' },
{ image: SITE + '/images/threejs-terrain-lod-splat-shader-grass.png', link: SITE + '/tutorials/threejs-terrain-lod-splat-shader-grass.html' }
];
// 2) Scene, camera, renderer + a bright happy gradient sky with a sun glow
// (same sky dome as the site's animated cube tutorial).
const isMobile = window.matchMedia('(pointer: coarse)').matches;
const scene = new THREE.Scene();
scene.fog = new THREE.Fog(0xcfe6f8, 30, 120);
const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 3, 0.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const sunDir = new THREE.Vector3().setFromSphericalCoords(1, THREE.MathUtils.degToRad(55), THREE.MathUtils.degToRad(35));
const sky = new THREE.Mesh(
new THREE.SphereGeometry(900, 32, 16),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
fog: false,
uniforms: {
uTop: { value: new THREE.Color(0x2f86e8) },
uHorizon: { value: new THREE.Color(0xcfe6f8) },
uSunDir: { value: sunDir }
},
vertexShader: `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uTop; uniform vec3 uHorizon; uniform vec3 uSunDir;
varying vec3 vDir;
void main() {
float h = max(vDir.y, 0.0);
vec3 col = mix(uHorizon, uTop, pow(h, 0.55));
float sun = max(dot(normalize(vDir), uSunDir), 0.0);
col += vec3(1.0, 0.92, 0.75) * (pow(sun, 600.0) * 2.0 + pow(sun, 12.0) * 0.25);
gl_FragColor = vec4(col, 1.0);
}`,
toneMapped: false
})
);
scene.add(sky);
scene.add(new THREE.HemisphereLight(0xbfe3ff, 0x1a3a10, 0.9));
const sun = new THREE.DirectionalLight(0xfff2d6, 1.6);
sun.position.set(-12, 16, 6);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.near = 1; sun.shadow.camera.far = 80;
sun.shadow.camera.left = -40; sun.shadow.camera.right = 40;
sun.shadow.camera.top = 40; sun.shadow.camera.bottom = -40;
sun.shadow.bias = -0.0004;
scene.add(sun);
// 3) Grass from the Endless Procedural Grass Field tutorial: fanned blade
// geometry, the same two-tone lit shader and a matching depth material for
// shadows. The wind is switched off, so the field stays perfectly still.
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(400, 400),
new THREE.MeshStandardMaterial({ color: 0x1a3a10, roughness: 0.95, metalness: 0.0 }));
ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground);
function makeBladeGeo(bladeWidth, bladeHeight, bladeMidHeight, spread) {
const numBlades = 4, verts = [], uvs = [];
for (let b = 0; b < numBlades; b++) {
const a = (b / numBlades) * Math.PI * 2, cos = Math.cos(a), sin = Math.sin(a);
const midX = cos * spread * 0.43, midZ = sin * spread * 0.43;
const tipX = cos * spread, tipZ = sin * spread;
verts.push(-bladeWidth/2*cos,0,-bladeWidth/2*sin, bladeWidth/2*cos,0,bladeWidth/2*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin);
verts.push(-bladeWidth/2*cos,0,-bladeWidth/2*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin, midX-bladeWidth/4*cos,bladeMidHeight,midZ-bladeWidth/4*sin);
verts.push(midX-bladeWidth/4*cos,bladeMidHeight,midZ-bladeWidth/4*sin, midX+bladeWidth/4*cos,bladeMidHeight,midZ+bladeWidth/4*sin, tipX,bladeHeight,tipZ);
uvs.push(0,0,1,0,1,0.33, 0,0,1,0.33,0,0.33, 0,0.33,1,0.33,0.5,1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
g.computeVertexNormals();
return g;
}
function makeGrassMaterials(baseHex, tipHex) {
const mat = new THREE.ShaderMaterial({
uniforms: { uBase: { value: new THREE.Color(baseHex) }, uTip: { value: new THREE.Color(tipHex) },
uSunDir: { value: new THREE.Vector3(-12, 16, 6).normalize() }, uSunColor: { value: new THREE.Color(0xfff2d6) } },
vertexShader: `
varying vec2 vUv; varying vec3 vNormal;
void main() {
vUv = uv;
vNormal = normalize(mat3(instanceMatrix) * normal);
gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
}`,
fragmentShader: `
uniform vec3 uBase; uniform vec3 uTip; uniform vec3 uSunDir; uniform vec3 uSunColor;
varying vec2 vUv; varying vec3 vNormal;
void main() {
vec3 col = mix(uBase, uTip, vUv.y);
float diff = abs(dot(normalize(vNormal), uSunDir));
float ao = mix(0.55, 1.0, vUv.y);
vec3 lit = col * ao * (0.5 + 0.9 * diff) + uSunColor * diff * 0.12 * vUv.y;
gl_FragColor = vec4(lit, 1.0);
}`,
side: THREE.DoubleSide
});
const depth = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vHighPrec;
void main() {
vec4 mvPos = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPos;
vHighPrec = gl_Position.zw;
}`,
fragmentShader: `
#include <packing>
varying vec2 vHighPrec;
void main() { gl_FragColor = packDepthToRGBA(0.5 * vHighPrec.x / vHighPrec.y + 0.5); }`,
side: THREE.DoubleSide
});
return { mat, depth };
}
// A round patch of radius GRASS_R around the menu; fog fades the rim into the sky.
const GRASS_R = 30;
function makeLayer(geo, matPair, count, sMin, sMax) {
const mesh = new THREE.InstancedMesh(geo, matPair.mat, count);
mesh.castShadow = true; mesh.customDepthMaterial = matPair.depth; mesh.frustumCulled = false;
const dummy = new THREE.Object3D();
for (let i = 0; i < count; i++) {
const r = Math.sqrt(Math.random()) * GRASS_R, a = Math.random() * Math.PI * 2, s = sMin + Math.random() * (sMax - sMin);
dummy.position.set(Math.cos(a) * r, 0, Math.sin(a) * r);
dummy.rotation.set(0, Math.random() * Math.PI * 2, 0);
dummy.scale.set(s, s * (0.85 + Math.random() * 0.3), s);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
scene.add(mesh);
return mesh;
}
// Type A: tall thin field grass. Type B: short wide bright tufts.
const density = isMobile ? 0.5 : 1;
makeLayer(makeBladeGeo(0.16, 1.8, 0.6, 0.35), makeGrassMaterials(0x1a4010, 0x4a9030), Math.round(24000 * density), 0.8, 1.3);
makeLayer(makeBladeGeo(0.26, 0.7, 0.3, 0.5), makeGrassMaterials(0x2b5a16, 0x8fc24a), Math.round(16000 * density), 0.9, 1.5);
// 4) Natural soft sprite clouds (same approach as the animated cube tutorial).
function makeCloudTexture() {
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d');
// Every puff stays fully inside the canvas (radius + position leave padding on
// all four sides), so each one fades to transparent before the edge: soft and
// puffy all round, no clipped straight edges.
for (let i = 0; i < 9; i++) {
const x = 80 + Math.random() * 96, y = 112 + Math.random() * 32, r = 30 + Math.random() * 30;
const grad = g.createRadialGradient(x, y, 0, x, y, r);
grad.addColorStop(0, 'rgba(255,255,255,0.9)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grad;
g.fillRect(0, 0, 256, 256);
}
return new THREE.CanvasTexture(c);
}
const clouds = new THREE.Group();
const cloudTex = makeCloudTexture();
for (let i = 0; i < 16; i++) {
const cloud = new THREE.Sprite(new THREE.SpriteMaterial({ map: cloudTex, transparent: true, opacity: 0.85, depthWrite: false, fog: false, toneMapped: false }));
const a = Math.random() * Math.PI * 2, d = 180 + Math.random() * 200;
cloud.position.set(Math.cos(a) * d, 45 + Math.random() * 70, Math.sin(a) * d);
cloud.scale.set(90 + Math.random() * 80, 45 + Math.random() * 30, 1);
clouds.add(cloud);
}
scene.add(clouds);
// 4b) Pine trees from Super Soldier Battle (the game's pine_better.glb, tree_A/B/C_LOD0).
// Each pine is authored lying along +Z, so it is stood upright inside a pivot
// group, then scaled, turned and placed. They stand well outside the ring of
// panels (radius 34 to 46) in the gaps between panels, so the view from the
// centre stays clear and they peek up over the menu like a forest edge.
function pineMaterial(src) {
const foliage = src.side === THREE.DoubleSide || src.alphaTest > 0 || src.transparent;
return new THREE.MeshLambertMaterial({ map: src.map || null, alphaTest: foliage ? 0.3 : 0, side: src.side });
}
const treeSpots = [
{ a: 15, r: 36, s: 0.17, v: 'A' }, { a: 48, r: 42, s: 0.20, v: 'C' },
{ a: 78, r: 34, s: 0.15, v: 'B' }, { a: 105, r: 44, s: 0.21, v: 'A' },
{ a: 135, r: 38, s: 0.17, v: 'C' }, { a: 165, r: 46, s: 0.22, v: 'B' },
{ a: 196, r: 35, s: 0.16, v: 'A' }, { a: 228, r: 41, s: 0.19, v: 'C' },
{ a: 255, r: 37, s: 0.18, v: 'B' }, { a: 287, r: 45, s: 0.21, v: 'A' },
{ a: 316, r: 34, s: 0.15, v: 'C' }, { a: 345, r: 40, s: 0.19, v: 'B' }
];
const gltfLoader = new GLTFLoader();
gltfLoader.setMeshoptDecoder(MeshoptDecoder);
gltfLoader.load('https://www.shanebrumback.com/models/glb/ssb-pine-trees.glb', function (gltf) {
treeSpots.forEach(function (spot, i) {
const proto = gltf.scene.getObjectByName('tree_' + spot.v + '_LOD0') || gltf.scene.getObjectByName('tree_A_LOD0');
const tree = proto.clone(true);
tree.traverse(function (child) {
if (child.isMesh) { child.material = pineMaterial(child.material); child.castShadow = true; }
});
tree.rotation.set(-Math.PI / 2, 0, 0); // stand it upright (+Z becomes +Y)
const pivot = new THREE.Group();
pivot.add(tree);
pivot.scale.setScalar(spot.s);
pivot.rotation.y = i * 2.4;
const ang = THREE.MathUtils.degToRad(spot.a);
pivot.position.set(Math.cos(ang) * spot.r, 0, Math.sin(ang) * spot.r);
pivot.updateMatrixWorld(true);
pivot.position.y -= new THREE.Box3().setFromObject(pivot).min.y; // base on the ground
scene.add(pivot);
});
});
// 5) The ring of image panels, each with a glow frame for the hover highlight.
const loader = new THREE.TextureLoader();
const radius = 14;
const step = (2 * Math.PI) / jsonData.length;
const panels = [];
jsonData.forEach(function (item, i) {
const texture = loader.load(item.image);
texture.colorSpace = THREE.SRGBColorSpace;
const group = new THREE.Group();
group.position.set(radius * Math.cos(step * i), 3.4, radius * Math.sin(step * i));
group.lookAt(0, 3.4, 0);
const glow = new THREE.Mesh(new THREE.PlaneGeometry(6.5, 3.55), new THREE.MeshBasicMaterial({ color: 0xffd84d, transparent: true, opacity: 0, fog: false }));
glow.position.z = -0.02;
const plane = new THREE.Mesh(new THREE.PlaneGeometry(6, 3.15), new THREE.MeshBasicMaterial({ map: texture, fog: false }));
plane.userData = { link: item.link, group: group, glow: glow, hover: 0 };
group.add(glow, plane);
scene.add(group);
panels.push(plane);
});
// 6) Orbit from the middle of the ring, clamped near eye level.
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 3, 0);
controls.minPolarAngle = THREE.MathUtils.degToRad(80);
controls.maxPolarAngle = THREE.MathUtils.degToRad(100);
controls.rotateSpeed = 0.35;
controls.enableZoom = false;
controls.enablePan = false;
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.6;
// 7) Hover to grow + highlight a panel, click or tap to open it.
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null, downX = 0, downY = 0;
function pick(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(panels, false);
return hits.length ? hits[0].object : null;
}
renderer.domElement.addEventListener('pointermove', function (event) {
if (event.pointerType !== 'mouse') return;
hovered = pick(event);
renderer.domElement.style.cursor = hovered ? 'pointer' : 'grab';
controls.autoRotate = !hovered; // pause the spin while aiming
});
renderer.domElement.addEventListener('pointerleave', function () { hovered = null; controls.autoRotate = true; });
renderer.domElement.addEventListener('pointerdown', function (event) { downX = event.clientX; downY = event.clientY; });
renderer.domElement.addEventListener('pointerup', function (event) {
if (Math.hypot(event.clientX - downX, event.clientY - downY) > 8) return; // it was a drag
const hit = pick(event);
if (!hit) return;
hovered = hit; // pop the tapped panel, then go
setTimeout(function () { window.location.href = hit.userData.link; }, event.pointerType === 'mouse' ? 0 : 180);
});
// 8) Render loop: slowly turning clouds, smooth hover scale, resize.
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1), t = clock.elapsedTime;
clouds.rotation.y += dt * 0.004;
panels.forEach(function (p) {
const u = p.userData;
u.hover += ((p === hovered ? 1 : 0) - u.hover) * Math.min(1, dt * 12);
u.group.scale.setScalar(1 + 0.12 * u.hover);
u.glow.material.opacity = 0.9 * u.hover;
});
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
What To Try Next
- Hover tooltip: show the tutorial title in an HTML label that follows the cursor while a panel is hovered.
- Smooth snapping: instead of free orbit, tween the ring so the nearest panel rotates to face front — a true carousel.
- Text labels: add a title under each image with a
CanvasTextureorCSS2DRenderer. - Mobile: the drag-to-orbit already works on touch; add a swipe to step one panel at a time.
Play My Free Browser Games
These are full Three.js experiences that run right in your browser — no download.