Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

iTechGuides is reader-supported. When you buy through links on our site, we may earn an affiliate commission. As an Amazon Associate I earn from qualifying purchases. Learn more

Build the experience as a normal, responsive web page with a Three.js canvas as its visual layer. Start with a scene, camera, renderer, one simple mesh, and an animation loop; then add a deliberate art direction, pointer picking, resize handling, and an HTML fallback. This order gives you a working page early and keeps the playful 3D layer understandable and usable.

What you are building

A whimsical Three.js site is not just a full-screen canvas with random objects. It is a page in which ordinary HTML carries the title, explanation, navigation, and important controls, while a 3D scene supplies atmosphere and an interactive focal point. The canvas can sit beside content, behind a hero section, or inside a bounded card. Keeping the page structure in HTML means the site still communicates when graphics are slow, unavailable, or disabled.

Three.js renders into a canvas. Its basic rendering pipeline has three required parts: a THREE.Scene containing objects, a camera that views them, and a renderer that draws the view. The official scene tutorial demonstrates the same sequence with a perspective camera, box geometry, a material, a mesh, and a render call (Creating a scene). Use that smallest possible scene to verify your project before adding models, shaders, or post-processing.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

1. Set up a real page and module entry point

Create a regular HTML document and load an ES module entry point. A local development server is important: module imports and assets should be tested through HTTP, not only by opening an HTML file directly from disk. The fundamentals manual explains the module-based approach (Three.js Fundamentals), while Discover three.js provides a longer learning path for building 3D web applications (How to Use This Book).

<!doctype html>
<html lang='en'>
  <head>
    <meta charset='utf-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    <title>Moonlit Meadow</title>
    <link rel='stylesheet' href='./styles.css'>
  </head>
  <body>
    <main>
      <section class='hero' aria-labelledby='hero-title'>
        <div class='copy'>
          <p class='eyebrow'>An interactive 3D postcard</p>
          <h1 id='hero-title'>Moonlit Meadow</h1>
          <p>Move your pointer over the meadow and discover the lanterns.</p>
          <button id='reset-camera' type='button'>Reset view</button>
        </div>
        <div class='scene-frame'>
          <canvas id='scene-canvas' aria-label='Animated moonlit meadow'></canvas>
        </div>
      </section>
    </main>
    <script type='module' src='./main.js'></script>
  </body>
</html>

Let CSS determine the displayed size of .scene-frame and its canvas. Do not hard-code a canvas width that only works on your development monitor. A simple first layout might use a two-column grid on wide screens and one column on narrow screens:

:root {
  color-scheme: dark;
  --ink: #f7f0d8;
  --paper: #15132a;
  --accent: #ffb86b;
}

body {
  margin: 0;
  min-width: 320px;
  background: var(--paper);
  color: var(--ink);
  font: 1rem/1.5 system-ui, sans-serif;
}

.hero {
  display: grid;
  grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 1.2fr);
  gap: 2rem;
  align-items: center;
  max-width: 75rem;
  min-height: 100svh;
  margin: 0 auto;
  padding: 2rem;
}

.scene-frame {
  position: relative;
  min-height: 28rem;
  overflow: hidden;
  border: 1px solid rgb(255 255 255 / 18%);
  border-radius: 2rem;
  background: #292454;
}

#scene-canvas {
  display: block;
  width: 100%;
  height: 100%;
}

@media (max-width: 48rem) {
  .hero { grid-template-columns: 1fr; }
  .scene-frame { min-height: 22rem; }
}

2. Make the smallest useful Three.js scene

In main.js, import Three.js through your project’s package setup, select the canvas, and create the three core objects. A perspective camera is a practical starting point for a storybook-like scene because nearby objects appear larger than distant ones.

import * as THREE from 'three';

const canvas = document.querySelector('#scene-canvas');
const frame = document.querySelector('.scene-frame');

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x292454);

const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1.4, 6);
camera.lookAt(0, 1, 0);

const renderer = new THREE.WebGLRenderer({
  canvas,
  antialias: true,
  alpha: false
});

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

const ambient = new THREE.HemisphereLight(0xffe8b6, 0x292454, 2);
scene.add(ambient);

function animate(time) {
  cube.rotation.y = time * 0.0004;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

The current WebGLRenderer API recommends defining ongoing animation with setAnimationLoop(). This loop receives a time value, updates the scene, and renders a frame. Render once before adding animation if you need to check camera framing; a static image makes composition errors easier to spot.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

3. Turn “whimsical” into an art direction

Whimsy works best as a set of consistent choices rather than a random collection of effects. Decide what the visitor should notice first, then use geometry, materials, color, depth, and motion to support that idea.

Use a small vocabulary of recognizable forms

Start with primitives such as spheres, capsules, cones, planes, and low-resolution boxes. A moon, lantern, cloud, mushroom, or tiny house can be assembled from a few shapes. Simple geometry loads quickly and gives you direct control over scale and animation. Replace primitives with detailed assets only after the interaction and camera composition are working.

Choose a limited palette

Give the background, ground, primary objects, and highlight state related colors. For example, a deep violet background, warm amber lights, and mint accents create a readable night scene. The Scene API supports a background color, while materials determine the visible color and shading of each mesh. Keep interactive highlights brighter or more saturated than the idle state so the response is legible without relying on motion alone.

Layer depth deliberately

Place a foreground object close to the camera, a clear subject in the middle distance, and quiet silhouettes farther back. Varying scale and movement speed creates depth even when every object is made from a primitive. Leave negative space around the subject so pointer feedback and text do not compete with the main shape.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Animate gently

Use slow rotations, small vertical bobbing, drifting particles, or a breathing light. Tie motion to elapsed time rather than to the number of frames so it remains consistent across devices. Reserve fast movement for an explicit response, such as a lantern brightening after selection. Constant high-frequency motion can make a page tiring and can obscure the visual hierarchy.

4. Organize objects so interaction stays manageable

Name objects and keep references to the meshes that can be selected. A small data structure makes it easier to change a material, show a label, or trigger an HTML update without searching the entire scene.

const lanterns = [];
const lanternMaterial = new THREE.MeshStandardMaterial({
  color: 0xffb86b,
  emissive: 0x3a1f08,
  emissiveIntensity: 0.8
});

for (let i = 0; i < 5; i += 1) {
  const lantern = new THREE.Mesh(
    new THREE.SphereGeometry(0.22, 20, 12),
    lanternMaterial.clone()
  );
  lantern.name = `lantern-${i + 1}`;
  lantern.position.set((i - 2) * 0.8, 0.9 + (i % 2) * 0.25, -0.4 - i * 0.15);
  lantern.userData.baseY = lantern.position.y;
  lantern.userData.selected = false;
  scene.add(lantern);
  lanterns.push(lantern);
}

Cloning the material gives each lantern an independent highlight state. For larger scenes, group related meshes in THREE.Group objects and raycast only against the interactive group rather than every decorative object.

5. Add pointer selection with a raycaster

Three-dimensional pointer selection is a raycasting problem: convert the pointer position to normalized device coordinates, cast a ray from the camera, and inspect the intersections. The Raycaster documentation describes this mouse-picking role.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
let hovered = null;

function updatePointer(event) {
  const rect = canvas.getBoundingClientRect();
  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}

function pick(event) {
  updatePointer(event);
  raycaster.setFromCamera(pointer, camera);
  const hits = raycaster.intersectObjects(lanterns, false);
  return hits.length ? hits[0].object : null;
}

function setHover(next) {
  if (hovered === next) return;
  if (hovered) {
    hovered.scale.setScalar(1);
    hovered.material.emissiveIntensity = 0.8;
  }
  hovered = next;
  if (hovered) {
    hovered.scale.setScalar(1.18);
    hovered.material.emissiveIntensity = 1.8;
  }
}

canvas.addEventListener('pointermove', (event) => {
  setHover(pick(event));
});

canvas.addEventListener('pointerleave', () => setHover(null));

canvas.addEventListener('pointerdown', (event) => {
  const selected = pick(event);
  if (!selected) return;
  selected.userData.selected = !selected.userData.selected;
  selected.material.color.set(
    selected.userData.selected ? 0x8ff0c1 : 0xffb86b
  );
});

Use getBoundingClientRect() so the coordinate calculation follows the canvas’s actual position and CSS size. Give hover a visible change in scale, color, or light, and give selection a persistent state. Touch devices may not produce hover, so the tap response must be understandable on its own.

When an interaction manager is a better fit

For a handful of objects, hand-built raycasting keeps the behavior explicit. If many objects need registered pointer events, the documented InteractionManager addon offers a separate interaction cycle. Its documented pattern includes connecting it with connect(renderer, camera), registering objects with add(...), and calling update() in the animation loop. Choose it for organization, not because the documentation establishes a universal performance advantage.

6. Make the canvas responsive without losing quality

CSS controls the displayed dimensions, but the renderer has its own drawing buffer and the camera has a projection aspect. Keep all three in sync whenever the drawing region changes. The official Responsive Design guide explains this relationship and discusses pixel ratio.

function resizeRendererToDisplaySize() {
  const width = frame.clientWidth;
  const height = frame.clientHeight;
  const pixelRatio = Math.min(window.devicePixelRatio, 2);
  const displayWidth = Math.floor(width * pixelRatio);
  const displayHeight = Math.floor(height * pixelRatio);
  const needsResize =
    canvas.width !== displayWidth || canvas.height !== displayHeight;

  if (needsResize) {
    renderer.setPixelRatio(pixelRatio);
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
  }

  return needsResize;
}

function animate(time) {
  resizeRendererToDisplaySize();
  for (const lantern of lanterns) {
    lantern.position.y = lantern.userData.baseY + Math.sin(time * 0.0015 + lantern.position.x) * 0.04;
  }
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

The capped pixel ratio in this example is a starting policy, not a guaranteed optimum. A very high device pixel ratio increases drawing work. Test the scene at realistic viewport sizes and on representative target devices, then choose a cap that preserves the visual quality you need without making interaction sluggish.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

7. Keep important meaning outside the canvas

Do not make a 3D object the only way to discover a fact, open a section, or complete a task. Keep meaningful text, headings, links, and ordinary buttons in HTML. If clicking a lantern reveals information, provide a list or button with the same information and state. The Three.js interaction documentation explains mechanics, not a complete screen-reader or keyboard implementation, so separately verify keyboard and assistive-technology operation for your finished page.

For keyboard users, make the HTML equivalent focusable and visible. For motion-sensitive visitors, provide a reduced-motion setting that pauses decorative animation while leaving essential state changes available. A canvas label can identify the visual region, but it does not expose the objects inside it as semantic controls.

8. Choose a renderer deliberately

For a conventional WebGL 2 project, use WebGLRenderer. The current API documents WebGL 2 as its requirement and states that WebGL 1 has not been supported since r163. It also recommends setAnimationLoop() for animation. The newer WebGPURenderer is documented as experimental, can fall back to WebGL 2, and has migration limitations. In particular, the manual notes that custom shader materials and the older EffectComposer post-processing route are unsupported in that path.

Renderer path Documented support and maturity Choose it when Check before committing
WebGLRenderer WebGL 2; WebGL 1 unsupported since r163 You need the maintained, established WebGL 2 route Confirm target browsers provide WebGL 2 and keep a non-3D fallback
WebGPURenderer Experimental; documents automatic WebGL 2 fallback Your target support and feature requirements have been checked against the current manual Account for migration limits, including custom shader materials and the older EffectComposer route

Do not assume WebGPU is always faster or universally better. Performance varies with scene setup, materials, lighting, resolution, and the device. Treat renderer selection as a project decision based on browser support, required features, maturity, and the cost of adapting your materials and post-processing.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

9. Handle unsupported graphics and test the real page

Three.js provides a WebGL capability helper that can check for WebGL 2 support and return a user-facing error message. Use that check before presenting a blank region. Keep the HTML copy and controls visible, and explain that the animated illustration is unavailable rather than hiding the entire page.

Use this verification checklist before launch:

  • Load the page through the same kind of local server and build process used for deployment.
  • Confirm the first frame has sensible camera framing before enabling elaborate animation.
  • Resize the viewport repeatedly, including a narrow phone layout, and verify that the canvas and camera remain aligned.
  • Test pointer movement, click, tap, pointer cancellation, and leaving the canvas.
  • Check that a tap still provides feedback when hover is unavailable.
  • Operate the equivalent HTML controls with a keyboard and inspect focus visibility.
  • Test with graphics unavailable or blocked and verify that the page still explains its purpose.
  • Profile representative target devices; reduce geometry, lights, particle count, or pixel ratio if frame time becomes excessive.

10. Troubleshoot common failures

The canvas is blank

Check that the module is being served over HTTP, the renderer is created with the intended canvas, and renderer.render(scene, camera) is reached. Then move the camera back, call camera.lookAt(...), and temporarily use a brightly colored primitive. A scene can be rendering correctly while the camera points away from every object.

The object appears stretched after resizing

Update camera.aspect from the drawing region’s current width divided by height and call camera.updateProjectionMatrix(). Also update the renderer size using the CSS dimensions and the chosen pixel ratio. Updating only one of these values leaves the projection or drawing buffer out of sync.

Picking works in one corner but not elsewhere

Calculate pointer coordinates from the canvas bounding rectangle, not from the full window. Convert the result to the normalized range from -1 to 1, invert the vertical axis, and cast with the same camera used for rendering. If decorative meshes intercept the ray, restrict intersectObjects to the interactive array.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The scene feels noisy

Remove effects until the main subject reads clearly. Reduce the number of colors, slow down secondary motion, and reserve bright emissive feedback for a hovered or selected object. Whimsy is easier to perceive when the visitor can tell what is interactive.

Mobile interaction feels heavy

Lower the drawing resolution cap, simplify geometry and shadows, and avoid animating every decorative object. Keep the camera and canvas dimensions correct first; visual polish should not hide a layout that is doing unnecessary work.

11. A practical build order

  1. Create the semantic HTML shell, a bounded canvas region, and a visible fallback message or control.
  2. Start a local module-based project and verify that the entry point loads through a development server.
  3. Create the scene, perspective camera, WebGL renderer, one mesh, and one render call.
  4. Add a background, lights, and a restrained palette; check composition at the intended canvas size.
  5. Replace the test mesh with a small set of named primitives and group decorative objects.
  6. Introduce gentle time-based motion through setAnimationLoop().
  7. Add raycaster selection or the InteractionManager addon, then make hover and tap states visibly different.
  8. Implement CSS-driven sizing, drawing-buffer resizing, camera aspect updates, and a sensible pixel-ratio policy.
  9. Add HTML equivalents for important actions, reduced-motion behavior, and a WebGL capability check.
  10. Test resizing, touch, keyboard access, unavailable graphics, and representative devices before adding optional effects.

Further learning

If you need a structured explanation of the concepts behind scenes, cameras, materials, and interaction, Discover three.js is presented as a book and tutorial series for creating 3D web applications. Treat it as learning material rather than a requirement for this build; the core scene can be developed directly from the official Three.js manuals linked above.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.