Halloween DealsAmazon USHalloween week deals: useful picks before month-endAmazon US: current deals, useful picks and tech finds.Check DealsWindows FixRecommendedWindows errors stealing your time? Find the fix fastScan stability, cleanup and performance issues.Fix NowHalloween DealsAmazon USOctober picks are moving - compare before checkoutAmazon US: current deals, useful picks and tech finds.See Picks×
FREEDOM251

Freedom251 › Blog › 3D design

How to Build a Whimsical Interactive 3D Website with Three.js

By Freedom251 Team · · 14 min read

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

Build the site as a normal, responsive web page with a Three.js canvas as its visual layer: create a scene, camera and renderer, add a few recognizable meshes, animate them with setAnimationLoop(), and use raycasting (or the documented InteractionManager addon) to make objects react to pointer input. Keep the page’s text, navigation and important controls in ordinary HTML so the experience remains understandable when 3D is slow, unsupported or unavailable.

This guide takes you from a blank module-based page to a playful scene, then covers art direction, picking, resizing, renderer decisions, accessibility and a practical verification pass.

As an Amazon Associate I earn from qualifying purchases.

1. Start with a real web page, not an isolated canvas

Three.js draws into an HTML canvas, but the canvas should be one region of your page rather than the whole information architecture. Put the title, explanatory copy, navigation, buttons and status messages in HTML. Give the canvas a deliberate container that can sit beside or behind that content.

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

Use an ES-module entry point and run the project through a local development server. The Three.js fundamentals material documents module imports and canvas rendering, while the companion learning guide explains the book’s approach to building web 3D applications (Three.js Fundamentals; Discover three.js: How to Use This Book). Opening an HTML file directly from disk is not a substitute for the same module and asset-loading conditions you will deploy.

A minimal HTML shell

<!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="page-title">
        <div class="copy">
          <p class="eyebrow">An interactive storybook</p>
          <h1 id="page-title">Moonlit Meadow</h1>
          <p>Explore the floating garden. Select a star to reveal its story.</p>
          <button id="reset-scene" type="button">Reset scene</button>
          <p id="scene-status" role="status" aria-live="polite"></p>
        </div>
        <div id="scene-container" class="scene-container">
          <canvas id="scene-canvas" aria-label="Interactive 3D moonlit meadow"></canvas>
        </div>
      </section>
    </main>
    <script type="module" src="./main.js"></script>
  </body>
</html>

The canvas receives a label here, but do not rely on that label to communicate every object or action. The nearby text and reset button provide a conventional path through the experience.

2. Build the smallest complete Three.js scene

A Three.js render begins with three objects: a Scene to hold content, a camera to define the view, and a renderer to draw the view. The official scene tutorial then adds a mesh made from geometry and material before rendering it (Creating a scene).

Import, create and render

import * as THREE from 'three';

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

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

const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 1.8, 7);
camera.lookAt(0, 0.7, 0);

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

const keyLight = new THREE.HemisphereLight(0xc7d2ff, 0x211a3b, 2.2);
scene.add(keyLight);

const pedestalGeometry = new THREE.CylinderGeometry(1.5, 1.8, 0.35, 32);
const pedestalMaterial = new THREE.MeshStandardMaterial({ color: 0x6d4f9a, roughness: 0.8 });
const pedestal = new THREE.Mesh(pedestalGeometry, pedestalMaterial);
pedestal.position.y = -0.8;
scene.add(pedestal);

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
});

Render once as soon as this works. A visible shape proves that the import, canvas, camera framing and renderer are connected. Only then add decorative geometry, animation and interaction; otherwise a later failure is difficult to localize.

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

Add a recognizable object

const starGeometry = new THREE.IcosahedronGeometry(0.55, 1);
const starMaterial = new THREE.MeshStandardMaterial({
  color: 0xffd66b,
  emissive: 0x5c3b16,
  emissiveIntensity: 0.35,
  roughness: 0.45,
  metalness: 0.05
});

const star = new THREE.Mesh(starGeometry, starMaterial);
star.name = 'Story star';
star.position.set(0, 0.45, 0);
scene.add(star);

const halo = new THREE.Mesh(
  new THREE.SphereGeometry(0.8, 24, 16),
  new THREE.MeshBasicMaterial({
    color: 0xffe7a3,
    transparent: true,
    opacity: 0.12,
    depthWrite: false
  })
);
halo.position.copy(star.position);
scene.add(halo);

Geometry describes the form; material describes how it is drawn. Keep the first scene deliberately small so you can judge composition, color and depth before importing complex models.

3. Turn “whimsical” into a repeatable visual system

Whimsy is more reliable when it is an art direction rather than a pile of effects. Choose a compact set of forms, colors and motions that reinforce the same story.

Use a limited cast of shapes

  • Choose three to five recognizable forms, such as a moon, a star, a mushroom and a floating island.
  • Repeat proportions and bevel-like softness so objects feel as though they belong to one illustrated world.
  • Place a foreground object, a middle group and a distant background element to create layered depth.
  • Give each interactive object a name and, if useful, a small data record describing its message or state.

A Scene can contain objects and a background, while meshes combine geometry and material to become visible content (Scene API; Creating a scene). Those primitives are enough to establish a coherent style before you consider external assets.

Make color do structural work

Pick one dominant background, one ground or support color, one highlight color and one interaction color. Reserve the interaction color for hover, focus and selected states. This keeps feedback legible even when the scene is busy.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
const palette = {
  night: 0x10152b,
  ground: 0x6d4f9a,
  glow: 0xffd66b,
  selected: 0x7cf4ff
};

scene.background = new THREE.Color(palette.night);
pedestal.material.color.setHex(palette.ground);
star.material.color.setHex(palette.glow);

Animate gently and purposefully

Use small rotations, bobbing, orbiting or opacity changes to make the scene feel alive. The motion should reveal depth or personality, not compete with the page’s message. Keep each object’s base transform so a reset action can restore it.

const animated = [
  { object: star, baseY: star.position.y, phase: 0 },
  { object: halo, baseY: halo.position.y, phase: Math.PI / 2 }
];

const clock = new THREE.Clock();

renderer.setAnimationLoop(() => {
  const elapsed = clock.getElapsedTime();

  for (const item of animated) {
    item.object.position.y = item.baseY + Math.sin(elapsed * 1.4 + item.phase) * 0.08;
    item.object.rotation.y += 0.004;
  }

  halo.scale.setScalar(1 + Math.sin(elapsed * 1.8) * 0.06);
  renderer.render(scene, camera);
});

The current WebGLRenderer API recommends defining the animation loop with setAnimationLoop(), and the beginner scene example uses the same method (WebGLRenderer; Creating a scene). Keep animation state separate from interaction state so a selection does not permanently corrupt the idle motion.

4. Add hover and click selection with raycasting

Pointer selection in 3D is a raycasting problem: convert the pointer position into normalized device coordinates, cast a ray from the camera and inspect the intersections. Three.js documents Raycaster specifically for finding which 3D object lies under the mouse pointer (Raycaster).

Prepare selectable objects

const selectable = [star];
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let hovered = null;
let selected = null;

const defaultScale = new THREE.Vector3(1, 1, 1);
const hoverScale = new THREE.Vector3(1.12, 1.12, 1.12);

function setPointerFromEvent(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) {
  setPointerFromEvent(event);
  raycaster.setFromCamera(pointer, camera);
  return raycaster.intersectObjects(selectable, false)[0]?.object ?? null;
}

function setHover(object) {
  if (hovered === object) return;
  if (hovered) hovered.scale.copy(defaultScale);
  hovered = object;
  if (hovered) hovered.scale.copy(hoverScale);
  canvas.style.cursor = hovered ? 'pointer' : 'default';
}

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

Use a visible state change, not a hidden event. Scaling, a color shift, a halo, a short motion or a status message can show that the object is pickable. Avoid changing only the cursor: touch users and keyboard users do not receive that signal.

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

Handle click and touch activation

function describe(object) {
  if (object === star) {
    status.textContent = 'The story star remembers every wish.';
  }
}

canvas.addEventListener('pointerup', (event) => {
  const object = pick(event);
  if (!object) return;

  selected = object;
  object.material.emissive.setHex(palette.selected);
  object.material.emissiveIntensity = 0.7;
  describe(object);
});

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

For many objects, or when you want a separately managed interaction layer, the documented InteractionManager addon provides connect(renderer, camera), add(...) and update() methods (InteractionManager). A hand-built raycaster is easy to reason about for a small number of selections; the addon can organize registered objects, but neither source supplies a universal performance winner.

Offer an ordinary HTML equivalent

If selecting the star reveals meaningful information, expose the same action outside the canvas. For a larger scene, create a list of buttons from the same data used to create meshes:

const stories = [
  { id: 'star', label: 'Story star', message: 'The story star remembers every wish.' }
];

const storyList = document.createElement('ul');
for (const story of stories) {
  const item = document.createElement('li');
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = story.label;
  button.addEventListener('click', () => {
    status.textContent = story.message;
    selected = star;
  });
  item.append(button);
  storyList.append(item);
}
document.querySelector('.copy').append(storyList);

This is implementation guidance rather than a complete accessibility standard. Keep meaningful text and conventional controls available, then separately verify keyboard and assistive-technology operation for the finished page. The interaction documentation describes object registration and updates, not a complete screen-reader recipe.

5. Keep the canvas responsive

Let CSS determine the displayed size, then synchronize the renderer’s drawing buffer and camera projection with the container. The responsive guide explains why CSS sizing and pixel-ratio handling must be coordinated (Responsive Design).

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

CSS layout that gives the canvas a real region

:root {
  color-scheme: dark;
  font-family: system-ui, sans-serif;
}

body {
  margin: 0;
  background: #10152b;
  color: #fff7df;
}

.hero {
  display: grid;
  grid-template-columns: minmax(16rem, 0.8fr) minmax(20rem, 1.2fr);
  align-items: center;
  gap: clamp(1rem, 4vw, 4rem);
  min-height: 100svh;
  padding: clamp(1rem, 5vw, 5rem);
  box-sizing: border-box;
}

.scene-container {
  width: 100%;
  min-height: min(70svh, 42rem);
  border-radius: 2rem;
  overflow: hidden;
  background: #171e3c;
}

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

@media (max-width: 760px) {
  .hero {
    grid-template-columns: 1fr;
  }

  .scene-container {
    min-height: 55svh;
  }
}

Resize from the drawing region

function resizeRendererToDisplaySize() {
  const width = container.clientWidth;
  const height = container.clientHeight;
  const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
  const drawingWidth = Math.floor(width * pixelRatio);
  const drawingHeight = Math.floor(height * pixelRatio);

  if (canvas.width !== drawingWidth || canvas.height !== drawingHeight) {
    renderer.setPixelRatio(pixelRatio);
    renderer.setSize(width, height, false);
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    return true;
  }

  return false;
}

renderer.setAnimationLoop(() => {
  resizeRendererToDisplaySize();
  renderer.render(scene, camera);
});

The container’s width and height are the source of truth. Updating only CSS can stretch a drawing buffer; updating only the renderer can leave the camera’s perspective wrong. Recalculate the camera aspect and call updateProjectionMatrix() whenever the region changes.

Pixel ratio is a quality-versus-cost decision. A high-density screen can multiply the number of pixels rendered, so cap it, test the result and choose a value appropriate for the target devices rather than blindly using the maximum available ratio.

6. Choose WebGLRenderer or WebGPURenderer deliberately

Renderer choice affects browser/API support, materials, post-processing and migration effort. The documented paths are not interchangeable labels for the same feature set.

Renderer path Documented support and status Important project implications Use it when
WebGLRenderer Uses WebGL 2. WebGL 1 has not been supported by this renderer since r163. The maintained choice for a pure WebGL 2 project; the API recommends setAnimationLoop(). Your target and existing libraries are built around WebGL 2 and you want the least migration risk.
WebGPURenderer Documented as experimental, with automatic WebGL 2 fallback. Custom shader materials and the older EffectComposer post-processing route are documented as unsupported; migration limitations must be checked. You have a clear WebGPU target, have audited required features and can accept an experimental path.

These qualifications come from the current renderer documentation (WebGLRenderer; WebGPURenderer). Do not promise that WebGPU is always faster or universally better: the manual notes that performance varies with scene setup.

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

Start with WebGL 2 when it fits the brief

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

This is the straightforward path for the scene in this guide. If you later evaluate WebGPU, make a feature inventory first: custom materials, shader code, post-processing, asset loaders and any third-party addons all need a compatibility check.

Check capability and provide a fallback

Three.js provides a WebGL capability helper that can check for WebGL 2 support and return a user-facing error message (WebGL capability helper). Place the check before constructing the renderer and keep the rest of the page usable when it fails.

import WebGL from 'three/addons/capabilities/WebGL.js';

if (!WebGL.isWebGL2Available()) {
  const message = WebGL.getWebGL2ErrorMessage();
  container.replaceChildren(message);
  status.textContent = 'The interactive scene is unavailable, but the story controls remain usable.';
} else {
  // Construct the scene and start the animation loop here.
}

The exact fallback should be part of the design: a static illustration, a short description, or the HTML story list can preserve the page’s purpose without pretending the 3D view is working.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

7. Organize scene state so playful features stay maintainable

Separate content data, object creation, animation and input. This makes it possible to add a second star without duplicating event logic.

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.

Describe content as data

const objects = [
  {
    id: 'star',
    label: 'Story star',
    position: [0, 0.45, 0],
    color: 0xffd66b,
    message: 'The story star remembers every wish.'
  },
  {
    id: 'moon',
    label: 'Listening moon',
    position: [-1.8, 1.6, -0.8],
    color: 0xc7d2ff,
    message: 'The moon listens for one kind thought.'
  }
];

Keep references for reset and cleanup

Store the original position, scale, material color and any animation phase when you create each mesh. A reset button can then restore state without rebuilding the entire scene.

const initialState = new Map();

function remember(object) {
  initialState.set(object, {
    position: object.position.clone(),
    rotation: object.rotation.clone(),
    scale: object.scale.clone(),
    color: object.material.color.getHex(),
    emissive: object.material.emissive.getHex(),
    emissiveIntensity: object.material.emissiveIntensity
  });
}

function resetScene() {
  for (const [object, state] of initialState) {
    object.position.copy(state.position);
    object.rotation.copy(state.rotation);
    object.scale.copy(state.scale);
    object.material.color.setHex(state.color);
    object.material.emissive.setHex(state.emissive);
    object.material.emissiveIntensity = state.emissiveIntensity;
  }
  selected = null;
  hovered = null;
  status.textContent = 'The meadow is ready to explore.';
}

document.querySelector('#reset-scene').addEventListener('click', resetScene);

8. Design for keyboard, touch and reduced dependence on motion

A canvas-only interface can hide meaning from people who cannot see the scene, cannot use a pointer or prefer less motion. Keep the title, instructions, status text and object controls in the DOM. Use buttons for actions that change content, and ensure focus states are visible.

  • Provide a visible instruction such as “Select a star to reveal its story,” then expose a matching HTML button or list.
  • Use pointer events rather than mouse-only handlers so touch and pen input can participate.
  • Do not make hover the only way to discover a state; click or tap should produce the same result.
  • Respect a reduced-motion preference by reducing bobbing, spinning and pulsing while leaving content available.
  • Keep status updates in an element with role="status" or an equivalent, and check that announcements are useful rather than repetitive.
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function motionAmount() {
  return reduceMotion.matches ? 0.015 : 0.08;
}

renderer.setAnimationLoop(() => {
  const elapsed = clock.getElapsedTime();
  star.position.y = 0.45 + Math.sin(elapsed * 1.4) * motionAmount();
  renderer.render(scene, camera);
});

reduceMotion.addEventListener('change', () => {
  // Re-render or update any UI that describes the motion mode.
});

There is no single Three.js API call that makes a canvas experience accessible. Treat accessibility as a page-level design and verification task, not as a side effect of adding a label.

9. Verify the experience before publishing

Run checks on the actual page, at the sizes and input methods your audience will use. The following are practical acceptance checks, not claims about a test already performed.

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

Scene and layout checks

  • Load through the same kind of local server and module entry point used by deployment.
  • Confirm the first render shows the intended composition before enabling decorative animation.
  • Resize from a wide desktop viewport to a narrow phone viewport and check that the camera framing remains intentional.
  • Rotate a phone or change the container’s aspect ratio; verify that the projection updates rather than stretching.
  • Test a high-density display and a lower-powered device; adjust pixel ratio or object count if frame cost is excessive.

Interaction checks

  • Move a pointer over every selectable object and confirm a visible hover state.
  • Click and tap each object; verify that the status text and visual selected state agree.
  • Try a pointer leaving and re-entering the canvas so stale hover states cannot remain.
  • Use the HTML controls with a keyboard, including focus, activation and reset.
  • Check that the same information is available without understanding the 3D composition.

Failure and fallback checks

  • Exercise the WebGL 2 capability check and confirm its message is readable.
  • Disable or block the 3D context if your test setup allows it; ensure the page still exposes its text and controls.
  • Watch the browser console for failed module or asset requests and fix path or server configuration errors before optimizing visuals.
  • Profile representative target devices instead of inferring performance from a development laptop.

10. Troubleshoot the failures that appear most often

The canvas is blank

First verify that the module loaded and that the renderer is created after the canvas exists. Then check that the camera points at the mesh, the mesh is inside the camera’s near/far range and the scene has a visible background or light. Render a single, brightly colored primitive before reintroducing custom materials.

The object is visible but picking misses

Use the canvas’s getBoundingClientRect(), not the window dimensions, when converting pointer coordinates. Recalculate coordinates after layout changes, and confirm that the raycaster’s camera is the same camera used for rendering. If you use nested meshes, decide whether to intersect child objects and map the hit back to the intended selectable parent.

The scene looks stretched after resizing

Read the container’s current width and height, call renderer.setSize(width, height, false), assign camera.aspect = width / height and call camera.updateProjectionMatrix(). Ensure CSS is not sizing the canvas independently of its container.

Animation is too expensive

Lower the capped pixel ratio, reduce unnecessary object count or simplify geometry, and avoid animating properties that do not need to change. Measure on representative devices; there is no renderer-wide performance guarantee. If you are considering WebGPU, audit feature compatibility first rather than switching solely on the expectation of speed.

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.

WebGPU migration breaks effects

Read the current WebGPURenderer manual before changing renderers. Its documented limitations include unsupported custom shader materials and the older EffectComposer post-processing route, so a project that depends on either may require a redesign rather than a drop-in replacement (WebGPURenderer).

11. A practical build order for your first whimsical scene

  1. Create the semantic HTML page and a canvas container with a useful fallback message or control.
  2. Start a local module-based development server and confirm the entry script loads.
  3. Create a scene, perspective camera and WebGLRenderer; render one primitive.
  4. Set the camera framing, background and a small light rig before adding detail.
  5. Introduce a limited palette and three to five recognizable forms.
  6. Store object references and initial transforms, then add gentle motion through setAnimationLoop().
  7. Add raycaster hover and click behavior for a small selectable set, or evaluate InteractionManager when registered-object management is more appropriate.
  8. Expose important actions and stories as ordinary HTML controls.
  9. Synchronize renderer size, pixel ratio and camera projection with the responsive container.
  10. Check WebGL 2 capability, provide a non-3D fallback and test keyboard, touch, resize and representative-device behavior.

For a structured continuation after this first build, Discover three.js presents a book and tutorial series focused on creating 3D web applications. Treat it as a learning resource, and verify any edition or retailer details independently before purchasing.

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.

3D designinteractive designJavaScriptThree.jsWeb developmentWebGL

Leave a Reply

Your email address will not be published. Required fields are marked *

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

Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
PC Slower Than It Used to Be?Free scan - under a minute

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.