Web Animations Hard
Three.js Scroll Camera Narrative
Narrative scene where scroll progress drives camera chapter rails.
Open in Lab
MCP
threejs camera scroll webgl
Targets: JS HTML
Code
:root {
--bg: #03060d;
--text: #eef4ff;
--muted: #c1d0e7;
--accent: #8fe8ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Avenir Next", "Segoe UI", sans-serif;
}
#scene {
position: fixed;
inset: 0;
z-index: 0;
}
.topbar {
position: fixed;
inset: 0 0 auto 0;
z-index: 30;
display: flex;
justify-content: space-between;
align-items: center;
padding: .75rem 1rem;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.topbar a {
color: var(--accent);
text-decoration: none;
font-weight: 700;
}
button {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 999px;
padding: .45rem .8rem;
background: rgba(255, 255, 255, 0.07);
color: var(--text);
cursor: pointer;
}
main {
position: relative;
z-index: 10;
}
.panel {
min-height: 88vh;
width: min(920px, 92%);
margin: 0 auto;
display: grid;
align-content: center;
gap: .7rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.label {
margin: 0;
color: var(--accent);
text-transform: uppercase;
letter-spacing: .1em;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(2rem, 7vw, 4.2rem);
}
p {
color: var(--muted);
max-width: 62ch;
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
import * as THREE from "three";
const host = document.getElementById("scene");
const toggle = document.getElementById("toggleMotion");
let motionEnabled = !window.MotionPreference.prefersReducedMotion();
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02040a);
scene.fog = new THREE.Fog(0x02040a, 8, 70);
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 120);
camera.position.set(0, 0.4, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
host.appendChild(renderer.domElement);
const points = new THREE.Group();
scene.add(points);
const palette = [0x8adfff, 0xbf8cff, 0xf2e4b0, 0xef5dd9];
for (let i = 0; i < 140; i += 1) {
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.11 + Math.random() * 0.28, 14, 14),
new THREE.MeshStandardMaterial({
color: palette[i % palette.length],
emissive: palette[i % palette.length],
emissiveIntensity: 0.28,
roughness: 0.35,
metalness: 0.2,
})
);
sphere.position.set((Math.random() - 0.5) * 12, (Math.random() - 0.5) * 6, -i * 0.48);
points.add(sphere);
}
scene.add(new THREE.AmbientLight(0x9dbfff, 0.44));
const key = new THREE.PointLight(0x9be0ff, 1.3, 40);
key.position.set(5, 6, 6);
scene.add(key);
const chapters = [
{ pos: new THREE.Vector3(0, 0.4, 12), look: new THREE.Vector3(0, 0, -2) },
{ pos: new THREE.Vector3(2.6, 0.8, -1), look: new THREE.Vector3(0, 0, -12) },
{ pos: new THREE.Vector3(-1.8, 0.6, -16), look: new THREE.Vector3(0.5, 0, -26) },
{ pos: new THREE.Vector3(0, 1.2, -33), look: new THREE.Vector3(0, 0, -46) },
];
function progress() {
const max = Math.max(document.body.scrollHeight - window.innerHeight, 1);
return Math.min(1, Math.max(0, window.scrollY / max));
}
function setLabel() {
toggle.textContent = motionEnabled ? "Disable motion" : "Enable motion";
}
function interpolateChapter(p) {
const scaled = p * (chapters.length - 1);
const i = Math.floor(scaled);
const t = Math.min(1, scaled - i);
const from = chapters[i];
const to = chapters[Math.min(chapters.length - 1, i + 1)];
const pos = from.pos.clone().lerp(to.pos, t);
const look = from.look.clone().lerp(to.look, t);
return { pos, look };
}
function animate() {
requestAnimationFrame(animate);
const p = motionEnabled ? progress() : 0;
const rail = interpolateChapter(p);
camera.position.lerp(rail.pos, 0.08);
camera.lookAt(rail.look);
points.children.forEach((m, i) => {
if (!motionEnabled) return;
m.position.y += Math.sin(performance.now() * 0.001 + i) * 0.0009;
m.rotation.y += 0.004;
});
renderer.render(scene, camera);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
toggle.addEventListener("click", () => {
motionEnabled = !motionEnabled;
setLabel();
});
window.addEventListener("resize", onResize);
setLabel();
animate();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo 13 - Scroll Camera Narrative</title>
<link rel="stylesheet" href="style.css" />
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
</head>
<body>
<header class="topbar">
<a href="../">Back to demos</a>
<button id="toggleMotion"></button>
</header>
<div id="scene"></div>
<main>
<section class="panel"><p class="label">Demo 13</p><h1>Scroll Camera Narrative</h1><p>Use scroll to move through scene chapters.</p></section>
<section class="panel"><h2>Chapter 1</h2><p>Approach foreground cluster.</p></section>
<section class="panel"><h2>Chapter 2</h2><p>Glide into core tunnel axis.</p></section>
<section class="panel"><h2>Chapter 3</h2><p>Finish with composed hero framing.</p></section>
</main>
<script type="module" src="script.js"></script>
</body>
</html>Three.js Scroll Camera Narrative
Narrative scene where scroll progress drives camera chapter rails.
Source
- Repository:
libs-gen - Original demo id:
13-threejs-scroll-camera-narrative
Notes
Narrative scene where scroll progress drives camera chapter rails.