Build a Scroll-Driven Three.js Page

Build a procedural Three.js landing page that responds to native scrolling.

Introduction

30 Second Summary

You’ll build a page where scrolling feels like moving through a living artwork. Each chapter changes the view, color, and mood of the world behind the story.

In this project, you will create a three-chapter story around a procedural Three.js sculpture. Native scrolling moves the camera and blends the scene between three visual states.

What You'll Build

You’ll scroll through Signal Garden and watch one sculpture shift from a quiet violet scene to warm amber and cool green compositions.

By the end of this project, you'll have:

  • A scroll-controlled story where moving down or back up the page transforms the same 3D world.
  • A procedural sculpture you can show without downloading images, models, or fonts.
  • A responsive production preview that respects reduced-motion preferences and stays below the 1 MB budget.
  • Secret Mission: Add a fourth chapter and visual state without changing the scroll engine.

Are there any prerequisites?

You do not need 3D development experience. You’ll set up each tool and see the page change as you build.

Before We Start

Before you start, decide what you’re building and why scrolling matters to it. This gives every camera move and color change a clear purpose.

Set Up Your Mac and Project

Signal Garden needs a reliable local workspace before you shape its 3D world. A consistent setup keeps later problems focused on your code instead of missing tools.

You’ll use Visual Studio Code to edit the project. Node.js supplies npm, which installs the exact Vite and Three.js versions used throughout the project.

Why use npm and Vite?

npm records exact package versions in your project. Vite uses those packages during development and turns your source into an optimized production build.

This workflow makes the final download easier to measure. It also gives you a setup you can reuse for future Three.js projects.

In this step, get ready to:
  • Prepare Visual Studio Code for terminal access.
  • Verify Node.js 24.19.0 LTS and npm.
  • Create the project folder and install its dependencies.
Prepare Visual Studio Code
  • Visit the Visual Studio Code download page in Chrome.
  • Download the macOS disk image.
  • Open the downloaded disk image in Finder.
  • Drag Visual Studio Code.app into Applications.
  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code and press Enter.
  • Press Cmd+Shift+P to open the Command Palette.
  • Select Shell Command: Install 'code' command in PATH.

Can't install the code command?

Close Terminal after installing the command. A new Terminal window reloads the command path.

Ask for help with installing the code command.

Verify Node.js and npm
  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter.
  • Check both installed versions by running:
node --version && npm --version

✔️ I see version 24.19.0 or higher

The first line shows Node.js 24.19.0 or higher. The next line confirms that npm is ready.

ⓧ I see an older version

Your current version is below this project’s pinned toolchain. Install Node.js 24.19.0 LTS before continuing.

  • Visit the Node.js download page.
  • Download Node.js 24.19.0 LTS for macOS.
  • Run the installer with its default options.
  • Restart Terminal.
  • Run the version check again.

ⓧ Command not found

Node.js is not installed yet. Its official macOS installer adds Node.js and npm together.

  • Visit the Node.js download page.
  • Download Node.js 24.19.0 LTS for macOS.
  • Run the installer with its default options.
  • Restart Terminal.
  • Run the version check again.

Still missing Node.js?

Close every Terminal window after the installer finishes. Open a new one so macOS reloads the command path.

Help me find Node.js after installation.

Create the project workspace
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the signal-garden folder and enter it by running:
mkdir signal-garden
cd signal-garden
  • Initialize npm and install the pinned dependencies by running:
npm init -y
npm install three@0.185.1
npm install --save-dev vite@8.2.1

What do these commands create?

  • npm init -y creates the package configuration.
  • three@0.185.1 provides the 3D library used by the page.
  • vite@8.2.1 provides the development and production commands.
  • Confirm the dependency files exist by running:
ls

You should see node_modules, package.json, and package-lock.json.

Did dependency installation fail?

Confirm the version check reports Node.js 24.19.0 or higher. Check your internet connection before running the install commands again.

Help me diagnose the npm installation.

Before the final check, what files do you expect npm to have created?

  • Verify your current folder and installed packages by running:
pwd && npm list --depth=0

The path ends in Desktop/signal-garden. The package list includes Three.js 0.185.1 and Vite 8.2.1.

Your tools and dependencies are ready. Next, you’ll turn this empty workspace into a visible story and its first 3D scene.

Create the Static Signal Garden

Your workspace has the right tools, but it does not have a page to show yet. This step creates the story first, then places a procedural 3D sculpture behind it.

You’ll build in small visible layers. Each saved layer gives you something concrete to check before the scene becomes more complex.

In this step, get ready to:
  • Create and serve the project files.
  • Build three full-screen story chapters.
  • Render a procedural sculpture behind the story.
Create and serve the project files
  • Create the starter files by running:
touch index.html style.css main.js
  • Confirm the files exist by running:
ls

You should see index.html, style.css, and main.js beside the npm files.

  • Open the current signal-garden folder in Visual Studio Code by running:
code .
  • Replace package.json with this configuration:
{
  "name": "signal-garden",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "three": "0.185.1"
  },
  "devDependencies": {
    "vite": "8.2.1"
  }
}

What does this configuration do?

  • dev starts the local editing server.
  • build creates optimized production files.
  • preview serves those production files locally.
  • Save package.json.
  • Start the Vite development server by running:
npm run dev

Vite prints a local URL and keeps this Terminal window busy. Leave it running while you build.

Does the development server fail?

Confirm Terminal is inside the signal-garden folder. Check that package.json contains the dev script.

Help me start the Vite server.

Build the scrolling story

The document needs two layers. The chapters scroll normally, while the graphics container stays fixed behind them.

  • Select index.html in the Explorer sidebar.
  • Add the document shell and fixed graphics layer:
<!doctype html>
<html lang="en">
  <head>
    <!-- Describe the page and load its styles. -->
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="description" content="Signal Garden, a scroll-driven Three.js story." />
    <title>Signal Garden</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <!-- Keep the 3D canvas behind the scrolling story. -->
    <div id="webgl" aria-hidden="true"></div>
    <header class="site-header">
      <a class="brand" href="#arrival">Signal Garden</a>
      <span>Scroll to conduct the world</span>
    </header>
    <main>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

How are the layers organized?

The webgl container receives the canvas. The empty main element is where the three chapters go.

  • Place the three chapter sections inside the empty main element:
      <!-- Give each visual state one full-screen chapter. -->
      <section class="chapter is-active" id="arrival">
        <div class="copy">
          <p class="eyebrow">01 / Arrival</p>
          <h1>Enter the quiet signal.</h1>
          <p>A field of markers gathers around a single luminous idea.</p>
        </div>
      </section>
      <section class="chapter" id="alignment">
        <div class="copy">
          <p class="eyebrow">02 / Alignment</p>
          <h2>Patterns become direction.</h2>
          <p>The world opens as every orbit finds a shared rhythm.</p>
        </div>
      </section>
      <section class="chapter" id="release">
        <div class="copy">
          <p class="eyebrow">03 / Release</p>
          <h2>Carry the signal forward.</h2>
          <p>What began as a point becomes a garden of possible paths.</p>
        </div>
      </section>
  • Save index.html.
  • Open the local URL printed by Vite in Chrome.

You should see the Signal Garden header followed by Arrival, Alignment, and Release in document order.

  • Select style.css in the Explorer sidebar.
  • Add the page palette and fixed layers:
/* Establish the dark canvas and predictable sizing. */
:root {
  color: #f5f1ff;
  background: #080713;
  font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  overflow-x: hidden;
  background: #080713;
}
/* Keep graphics fixed while the story scrolls above them. */
#webgl {
  position: fixed;
  inset: 0;
  z-index: 0;
}
#webgl canvas {
  display: block;
  width: 100%;
  height: 100%;
}
main {
  position: relative;
  z-index: 2;
}

Why fix the graphics layer?

The canvas fills the viewport without joining the document flow. The higher stacking level keeps the chapter text visible above it.

  • Add the header and chapter layout below the existing styles:
/* Keep navigation visible above every scene. */
.site-header {
  position: fixed;
  z-index: 3;
  width: 100%;
  display: flex;
  justify-content: space-between;
  padding: 1.5rem 2rem;
  color: rgba(255, 255, 255, 0.66);
  font-size: 0.75rem;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.brand {
  color: #fff;
  text-decoration: none;
  font-weight: 700;
}
/* Turn each chapter into one screen of scrolling distance. */
.chapter {
  min-height: 100svh;
  display: flex;
  align-items: center;
  padding: 8rem clamp(1.5rem, 8vw, 8rem);
  opacity: 0.35;
  transition: opacity 300ms ease;
}
.chapter.is-active { opacity: 1; }
  • Add the copy and typography rules at the end of style.css.
/* Protect the story text from the moving background. */
.copy {
  width: min(38rem, 82vw);
  padding: 1.5rem;
  border-left: 1px solid rgba(255, 255, 255, 0.35);
  background: linear-gradient(90deg, rgba(5, 5, 12, 0.72), transparent);
  text-shadow: 0 2px 20px #000;
}
.eyebrow {
  color: #d8c8ff;
  font-size: 0.72rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
h1, h2 {
  max-width: 10ch;
  margin: 0;
  font-family: Georgia, "Times New Roman", serif;
  font-size: clamp(3rem, 7vw, 7rem);
  font-weight: 400;
  line-height: 0.92;
}
.copy > p:last-child {
  max-width: 32rem;
  color: rgba(255, 255, 255, 0.72);
  line-height: 1.6;
}
  • Save style.css.
  • Refresh Chrome.
  • Scroll through all three chapters.

You should see three full-screen chapters on a dark page. The header stays fixed while Arrival remains brighter than the other chapters.

Does the story look unstyled?

Confirm index.html loads /style.css. Check that each CSS rule has a closing brace.

Help me check the static story.

Render the procedural world

WebGL lets the browser draw the scene through the graphics processor. Three.js gives you simpler objects for the scene, camera, geometry, materials, and lights.

  • Select main.js in the Explorer sidebar.
  • Add the import and guarded renderer setup:
import * as THREE from 'three';

// Mount the renderer in the fixed graphics layer.
const mount = document.querySelector('#webgl');
let renderer;

try {
  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
  mount.appendChild(renderer.domElement);
} catch (error) {
  // Keep the story readable if WebGL cannot start.
  document.documentElement.classList.add('no-webgl');
  console.error('WebGL is unavailable.', error);
}

if (renderer) {
  // Build the complete 3D world only when rendering is available.
}

What does the renderer setup do?

  • renderer turns the scene into pixels on the canvas.
  • try and catch keep the written story available if WebGL cannot start.
  • if (renderer) prevents the scene setup from running after a renderer failure.
  • Place the scene foundation inside if (renderer) before its closing brace:
  // Create the stage, camera, and group that moves as one world.
  const scene = new THREE.Scene();
  scene.background = new THREE.Color('#080713');
  scene.fog = new THREE.FogExp2('#080713', 0.045);

  const camera = new THREE.PerspectiveCamera(
    42,
    window.innerWidth / window.innerHeight,
    0.1,
    100
  );
  camera.position.set(0, 0.4, 10);

  const world = new THREE.Group();
  scene.add(world);

  // Give the sculpture a luminous faceted center.
  const coreMaterial = new THREE.MeshStandardMaterial({
    color: '#f1eaff',
    emissive: '#5d38a8',
    emissiveIntensity: 0.8,
    flatShading: true,
    metalness: 0.2,
    roughness: 0.35
  });
  const core = new THREE.Mesh(new THREE.IcosahedronGeometry(1.35, 1), coreMaterial);
  world.add(core);

How does the scene take shape?

The scene holds everything Three.js renders. The perspective camera creates depth, while the world group lets later scroll states rotate the whole composition together.

The icosahedron uses flat shading to keep its faces visible. Its emissive color gives the center a soft glow.

  • Add the orbiting rings below the core setup:
  // Surround the core with reusable orbiting geometry.
  const ringMaterial = new THREE.MeshStandardMaterial({
    color: '#bca8ff',
    emissive: '#392178',
    emissiveIntensity: 0.45,
    metalness: 0.55,
    roughness: 0.3
  });
  const rings = [];

  for (let index = 0; index < 3; index += 1) {
    const ring = new THREE.Mesh(
      new THREE.TorusGeometry(2.15 + index * 0.55, 0.055, 8, 64),
      ringMaterial
    );
    ring.rotation.set(index * 0.65, index * 0.8, index * 0.3);
    rings.push(ring);
    world.add(ring);
  }
  • Add the instanced pillar field below the rings:
  // Reuse one mesh definition for a lightweight field of markers.
  const pillarGeometry = new THREE.CylinderGeometry(0.055, 0.09, 1, 6);
  const pillarMaterial = new THREE.MeshStandardMaterial({
    color: '#6d5a9d',
    roughness: 0.65
  });
  const pillars = new THREE.InstancedMesh(pillarGeometry, pillarMaterial, 72);
  const marker = new THREE.Object3D();

  for (let index = 0; index < 72; index += 1) {
    const angle = (index / 72) * Math.PI * 2;
    const radius = 4.2 + (index % 9) * 0.18;
    marker.position.set(Math.cos(angle) * radius, -2.1, Math.sin(angle) * radius);
    marker.scale.set(1, 0.7 + (index % 7) * 0.22, 1);
    marker.updateMatrix();
    pillars.setMatrixAt(index, marker.matrix);
  }
  pillars.instanceMatrix.needsUpdate = true;
  world.add(pillars);

Why use instancing?

InstancedMesh draws many copies from one geometry and material. Each matrix gives one pillar its position and height, which keeps the field efficient.

  • Add the lights and animation below the pillar field:
  // Light the sculpture from two colored directions.
  scene.add(new THREE.AmbientLight('#8a78b8', 0.7));
  const keyLight = new THREE.PointLight('#9a78ff', 55, 30, 2);
  keyLight.position.set(4, 5, 6);
  scene.add(keyLight);
  const rimLight = new THREE.PointLight('#ff9a62', 38, 30, 2);
  rimLight.position.set(-5, 1, -4);
  scene.add(rimLight);

  // Match the first frame to the current viewport.
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
  renderer.setSize(window.innerWidth, window.innerHeight, false);

  let previousTime = 0;
  function animate(time) {
    const delta = Math.min((time - previousTime) / 1000, 0.05);
    previousTime = time;
    core.rotation.y += delta * 0.12;
    core.rotation.x += delta * 0.04;
    renderer.render(scene, camera);
  }

  renderer.setAnimationLoop(animate);

What makes the scene feel alive?

  • Ambient light keeps the shadowed faces visible.
  • The violet key light defines the main color.
  • The warm rim light separates the sculpture from the background.
  • animate() rotates the core and renders the latest frame.

Before you refresh, where do you expect the sculpture to appear relative to the chapter text?

  • Save main.js.
  • Refresh Chrome.
  • Scroll through all three chapters.

A faceted core, three rings, and a circular pillar field now sit behind the story. The sculpture rotates gently while the chapters scroll over it.

Is the sculpture missing?

Open the browser console and check the first reported line. A missing brace usually means one of the scene chunks sits outside if (renderer).

Help me find the missing scene.

Your story now has a live world behind it. Next, you’ll make scrolling change the camera, sculpture, lighting, and active chapter together.

Make Scrolling Conduct the World

Your Three.js world now renders behind all three chapters. The story moves, but the sculpture still holds the same composition from top to bottom.

You’ll turn the page’s scroll position into one progress value between 0 and 1. That value becomes a shared timeline for the camera, sculpture, colors, lights, and chapter emphasis.

In this step, get ready to:
  • Describe three complete visual states.
  • Blend between neighboring states as the page scrolls.
  • Keep the highlighted chapter synchronized with the scene.
Describe the three visual states

A visual state is a snapshot of the whole composition. Each one stores where the camera sits, where it looks, how the sculpture turns, and which colors light the scene.

  • Add the chapter and motion references below the mount declaration in main.js.
// Connect the page chapters and motion preference to the scene.
const chapters = [...document.querySelectorAll('.chapter')];
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
  • Remove the static camera.position.set() line from the renderer block.
  • Add the first two visual states below the light setup:
  // Define the opening violet composition.
  const states = [{
    camera: new THREE.Vector3(0, 0.4, 10),
    target: new THREE.Vector3(0, 0, 0),
    rotation: [0.1, 0, 0],
    ringScale: 0.82,
    background: new THREE.Color('#080713'),
    key: new THREE.Color('#9a78ff'),
    rim: new THREE.Color('#ff9a62')
  }, {
    // Open the middle chapter into a warm side view.
    camera: new THREE.Vector3(7.2, 2.2, 6.5),
    target: new THREE.Vector3(0, 0.3, 0),
    rotation: [0.65, 1.1, -0.25],
    ringScale: 1.15,
    background: new THREE.Color('#160b08'),
    key: new THREE.Color('#ff8d52'),
    rim: new THREE.Color('#ffd18a')
  },
  • Add the final state directly below the first two:
  {
    // Lift the final chapter into a cool green composition.
    camera: new THREE.Vector3(-6.4, 4.8, 7.6),
    target: new THREE.Vector3(0, -0.4, 0),
    rotation: [1.05, 2.35, 0.4],
    ringScale: 1.52,
    background: new THREE.Color('#041713'),
    key: new THREE.Color('#65f0b1'),
    rim: new THREE.Color('#a5b8ff')
  }];

How are the states organized?

Arrival uses a centered violet view. Alignment moves to a warm side angle, while Release lifts the camera and shifts the scene toward green.

Keeping these values together makes each chapter easy to tune. One state always describes one complete visual idea.

Turn scrolling into progress

At the top of the page, progress is 0. At the bottom, it is 1. Halfway down the page produces a value near 0.5.

  • Add the reusable interpolation values and progress function below states.
  // Reuse these objects instead of creating new ones every frame.
  const cameraTarget = new THREE.Vector3();
  const backgroundColor = new THREE.Color();
  const keyColor = new THREE.Color();
  const rimColor = new THREE.Color();

  function getProgress() {
    // Divide the current position by the complete scrollable distance.
    const distance = document.documentElement.scrollHeight - window.innerHeight;
    return distance > 0
      ? THREE.MathUtils.clamp(window.scrollY / distance, 0, 1)
      : 0;
  }

  function getBlend() {
    // Select two neighboring states and the blend between them.
    const scaled = getProgress() * (states.length - 1);
    const fromIndex = Math.min(Math.floor(scaled), states.length - 2);
    const rawMix = scaled - fromIndex;
    const mix = reducedMotion.matches
      ? Math.round(rawMix)
      : THREE.MathUtils.smoothstep(rawMix, 0, 1);
    return { scaled, fromIndex, toIndex: fromIndex + 1, mix };
  }

How does one value control three states?

  • scaled stretches page progress across the two gaps between three states.
  • fromIndex and toIndex select the states on either side of the current position.
  • mix describes how far the scene has moved between those two states.
  • Add the camera and sculpture interpolation below getBlend().
  function applyTransform(from, to, mix) {
    // Move the camera and its look target through the same blend.
    camera.position.lerpVectors(from.camera, to.camera, mix);
    cameraTarget.lerpVectors(from.target, to.target, mix);
    camera.lookAt(cameraTarget);

    // Rotate the whole world between the two compositions.
    world.rotation.set(
      THREE.MathUtils.lerp(from.rotation[0], to.rotation[0], mix),
      THREE.MathUtils.lerp(from.rotation[1], to.rotation[1], mix),
      THREE.MathUtils.lerp(from.rotation[2], to.rotation[2], mix)
    );

    // Expand or contract every ring from one shared scale.
    const ringScale = THREE.MathUtils.lerp(from.ringScale, to.ringScale, mix);
    rings.forEach((ring, index) => {
      ring.scale.setScalar(ringScale * (1 + index * 0.04));
    });
  }
  • Add the color and chapter interpolation below applyTransform().
  function applyAtmosphere(from, to, mix, scaled) {
    // Blend the background, fog, and both colored lights.
    scene.background.copy(backgroundColor.lerpColors(from.background, to.background, mix));
    scene.fog.color.copy(backgroundColor);
    keyLight.color.copy(keyColor.lerpColors(from.key, to.key, mix));
    rimLight.color.copy(rimColor.lerpColors(from.rim, to.rim, mix));

    // Highlight the chapter closest to the current visual state.
    const activeIndex = Math.round(scaled);
    chapters.forEach((chapter, index) => {
      chapter.classList.toggle('is-active', index === activeIndex);
    });
  }

  function applyScrollState() {
    // Apply one shared scroll blend to every visual property.
    const { scaled, fromIndex, toIndex, mix } = getBlend();
    const from = states[fromIndex];
    const to = states[toIndex];
    applyTransform(from, to, mix);
    applyAtmosphere(from, to, mix, scaled);
  }

Why split the interpolation?

The transform helper handles spatial changes. The atmosphere helper handles color and chapter emphasis, while applyScrollState() sends both helpers the same blend.

Because every property reads the same timeline, the page feels coordinated instead of triggering unrelated effects.

Synchronize the world and chapters
  • Find previousTime = time inside animate().
  • Add this call directly below it:
    // Read the latest native scroll position before rendering.
    applyScrollState();

Why update inside the animation loop?

The animation loop reads the browser’s current scroll position before every frame. Scrolling forward or backward therefore runs the same timeline in either direction.

Before you test, which properties do you expect to reverse when you scroll back toward Arrival?

  • Save main.js.
  • Refresh Chrome.
  • Scroll from Arrival to Release.
  • Scroll back to Arrival.

The camera, world rotation, ring scale, background, fog, lights, and active chapter now change together. Every transition reverses as you scroll upward.

Does the world stay static?

Confirm applyScrollState() runs inside animate() before the renderer call. Check the browser console for the first variable or bracket error.

Help me trace the scroll state.

Your story and sculpture now share one reversible timeline. Next, you’ll keep that experience readable and comfortable across different screens and motion preferences.

Add Responsive and Reduced-Motion Behavior

Your scroll-controlled world now moves through three reversible states. On a different screen, the same canvas can stretch or ask the graphics processor to draw more pixels than the visitor needs.

This step keeps the composition aligned with the viewport. It also gives visitors who prefer less motion a stable version of the same story.

In this step, get ready to:
  • Keep the renderer and camera aligned with the viewport.
  • Make the chapter copy readable at 390 pixels wide.
  • Stop ambient motion when reduced motion is active.
Keep the canvas and camera aligned

The renderer controls the canvas dimensions. The camera’s aspect ratio controls how that scene fits inside the canvas, so both values must change together.

  • Add resize() above animate() in main.js.
  function resize() {
    // Limit internal resolution on high-density screens.
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    renderer.setSize(window.innerWidth, window.innerHeight, false);

    // Match the camera shape to the resized viewport.
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
  }

What does the resize function protect?

  • setPixelRatio() caps the internal drawing resolution at 1.5 times the CSS resolution.
  • setSize() matches the canvas to the viewport without replacing its CSS size.
  • updateProjectionMatrix() applies the camera’s new aspect ratio.
  • Replace the existing renderer startup lines with:
  // Size the first frame and react to later viewport changes.
  window.addEventListener('resize', resize);
  resize();
  applyScrollState();
  renderer.setAnimationLoop(animate);

Before you resize the browser, how should a round ring look after the viewport becomes narrow?

  • Save main.js.
  • Resize the Chrome window horizontally.

The canvas continues to fill the viewport. The rings stay round instead of stretching into ovals.

Does the sculpture look stretched?

Confirm camera.aspect divides the viewport width by its height. Keep camera.updateProjectionMatrix() inside resize().

Help me fix the stretched canvas.

Make the mobile story readable

Wide screens leave room beside the sculpture. On a narrow screen, the copy needs the full width and a stronger background so it stays readable over the moving scene.

  • Add the mobile layout at the end of style.css.
/* Protect the story composition on narrow screens. */
@media (max-width: 640px) {
  .site-header {
    padding: 1rem;
  }
  .site-header span {
    display: none;
  }
  .chapter {
    align-items: flex-end;
    padding: 6rem 1rem 3rem;
  }
  .copy {
    width: 100%;
    background: rgba(5, 5, 12, 0.82);
    backdrop-filter: blur(8px);
  }
  h1, h2 {
    font-size: clamp(2.8rem, 15vw, 4.8rem);
  }
}

How does the mobile layout help?

The copy moves toward the bottom, where it covers less of the sculpture. Its darker panel keeps the text legible, while the heading size follows the narrow viewport.

  • Save style.css.
  • Open Chrome DevTools.
  • Select the device toolbar in the DevTools toolbar.
  • Enter 390 as the viewport width.
  • Scroll through all three chapters.

The copy sits near the bottom and fills the available width. The headings remain readable while the sculpture keeps its proportions.

Is the mobile copy hard to read?

Confirm the emulated width is 390 pixels. Check that the media query still targets max-width: 640px.

Help me fix the mobile layout.

Respect reduced-motion preferences

The scroll states already snap instead of blend when reduced motion is active. The remaining continuous movement comes from the core’s ambient rotation.

  • Replace animate() with this motion-aware version:
  function animate(time) {
    // Keep time-based motion stable after pauses or slow frames.
    const delta = Math.min((time - previousTime) / 1000, 0.05);
    previousTime = time;
    applyScrollState();

    // Stop ambient motion for reduced-motion visitors and hidden tabs.
    if (!reducedMotion.matches && !document.hidden) {
      core.rotation.y += delta * 0.12;
      core.rotation.x += delta * 0.04;
    }

    renderer.render(scene, camera);
  }

What changes for reduced motion?

The scroll state still updates so the visitor can move through the story. Only the continuous ambient rotation stops, which preserves the chapter structure without unnecessary motion.

Before the final check, how should the core and chapter transitions behave with reduced motion active?

  • Save main.js.
  • Emulate reduced motion in Chrome DevTools.
  • Set the viewport width to 390 pixels.
  • Scroll from Arrival to Release.
  • Resize the viewport horizontally.

The copy remains readable and the canvas keeps its proportions. The scene snaps between chapter states, while the core no longer rotates on its own.

Does the core still rotate?

Reload the page after changing the emulated preference. Confirm the rotation lines remain inside the !reducedMotion.matches condition.

Help me stop the ambient motion.

Signal Garden now adapts to the visitor’s screen and motion preference. Next, you’ll create the production files and measure exactly what the finished page ships.

Build and Prove the 1 MB Budget

Your responsive Signal Garden now works in development. Visitors do not receive your source folder or node_modules when you deploy a Vite project.

Vite turns the source into a smaller dist folder. You’ll build that folder, measure its allocated disk size, and preview the exact optimized files.

In this step, get ready to:
  • Create the optimized production files.
  • Measure the complete output against the 1 MB budget.
  • Preview the production experience.
Create the optimized build

The development server favors quick updates and useful debugging. A production build bundles the page into optimized static assets that are ready for hosting.

  • Return to the Terminal window running the development server.
  • Press Control+C to stop the server.
  • Create the production build by running:
npm run build

What does the build command do?

The build script runs Vite in production mode. Vite writes the optimized HTML, CSS, and JavaScript into dist.

  • Confirm the terminal reports a completed build.
  • Confirm dist exists by running:
ls dist

The output includes index.html and an assets directory containing the optimized CSS and JavaScript.

Is the build failing?

Confirm Terminal is inside the signal-garden folder. Check the first build error, because later messages often follow from that one problem.

Help me diagnose the production build.

Measure the complete output

The budget covers everything inside dist, including the bundled Three.js code. The du check reports allocated disk space in units of 1024 bytes, so it is a conservative local check rather than a compressed network-transfer measurement.

Before you run the measurement, do you expect the procedural scene to stay below 1 MB without image, model, audio, or font files?

  • Rebuild the project and measure dist by running:
npm run build && du -sk dist

How is the budget measured?

The first command refreshes the production files. The second reports one total for dist in 1024-byte units.

A first number below 1024 keeps the complete output under the project’s 1 MB disk-allocation budget.

  • Read the first number on the final output line.
  • Confirm the number is below 1024.

Is the build over budget?

Confirm you measured only dist. Images, models, audio, and other downloaded assets can quickly increase the result.

Help me find the largest build files.

Preview the production files

A production preview serves the exact files inside dist. This final test checks that optimization preserved the experience you built.

Before you start the preview, do you expect it to look and behave exactly like the development version?

  • Start the production preview by running:
npm run preview

Vite prints a new local URL and keeps the Terminal window busy. Press Control+C when you want to stop it.

  • Copy the local preview URL printed by Vite.
  • Paste the URL into the Chrome address bar.
  • Press Enter.
  • Scroll from Arrival to Release.
  • Scroll back to Arrival.
  • Resize the Chrome window.
  • Check the Console panel in Chrome DevTools.

The production preview keeps the same reversible camera choreography and responsive layout. The Console panel shows no errors.

Does the preview differ from development?

Keep the preview command running while testing. Rebuild before restarting the preview if Chrome shows an older result.

Help me compare the preview and development versions.

Secret mission

Add a Fourth World State

Your three-state timeline already knows how to scale. Add an original fourth chapter and matching visual state, then prove the scroll engine handles both without a rewrite.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The local signal-garden folder containing your source files and production build.
  • The Vite production preview running from your terminal.

Keep everything running

No action needed. Choose this if you are still exploring Signal Garden or want to extend it later.

  • Keep the Vite preview running while you test the project.
  • Keep the signal-garden folder for future changes.

Pause - I'll come back to this later

Shut down the running preview to free up memory while keeping every project file.

  • Switch to the Terminal window running the preview.
  • Press Control+C to stop the server.
  • Keep the signal-garden folder on your Desktop.

Delete - I don't want to use this again

Remove the local project if you no longer want its source or production files.

  • Switch to the Terminal window running the preview.
  • Press Control+C to stop the server.
  • Move above the project folder and delete it by running:
cd ..
rm -rf signal-garden

What do these commands remove?

cd .. moves above the project. rm -rf signal-garden permanently removes its source, dependencies, and production output.

  • Confirm the folder is gone by running:
ls

The signal-garden folder no longer appears in the Desktop listing.

Still see the project folder?

Confirm you were inside signal-garden before running cd ... Check the spelling and letter case of the folder name.

Help me safely remove the project folder.

Nice Work!

Nice Work!

Nice work! You built a scroll-driven 3D landing page around an original procedural Three.js world. The production preview now moves through three art-directed chapters in either direction.

You've learned how to:

  • Build a live editorial experience where a procedural sculpture sits behind three full-screen chapters.
  • Control a 3D world with native scrolling by blending camera poses, sculpture transforms, lights, colors, and chapter emphasis.
  • Deliver a resilient production build with responsive rendering, reduced-motion behavior, and a complete output below 1 MB.
  • Complete the Secret Mission by adding an optional fourth chapter and state without rewriting the scroll engine.

Ready to quiz yourself?