An interactive 3D vehicle experience. One continuous cinematic take, driven entirely by scroll: the camera, the exploded view, the lighting, the copy and the soundtrack all read from a single timeline.
NOVA is a fictional marque, built as an interactive study.
pnpm install
pnpm dev # http://localhost:5173
pnpm build # production bundle
pnpm preview # serve the build
pnpm lint # oxlint| Library | Version | Role |
|---|---|---|
| React | 19 | UI and state |
| Vite | 8 | Build and dev server |
| Three.js | 0.185 | Rendering and scene |
| GSAP + ScrollTrigger | 3.15 | Scroll choreography |
| Lucide React | 1.31 | Icons |
No TypeScript, no CSS framework. Styles are hand-written CSS.
Two rules hold the whole project together.
React never touches the 3D scene. It mounts a canvas, constructs Experience, and
stays out of the way. Everything the scroll controls is written to one plain object:
GSAP timeline ──writes──▶ state ──reads──▶ render loop ──▶ Three.js
state = {
cam, // camera target: position, look point, lens, drift, rise
explode, // 0 → 1 per part group
lift, // how far the assembly floats off the floor
progress, // scroll position, drives annotation presence
disassembly, // 0 → 1, read by the audio bed
focus, // accent light position and intensity
exposure, // tone-mapping exposure
}There is exactly one ScrollTrigger. Nothing else in the app is allowed its own. That
is why the copy always lands on the same frame as the camera — they are literally the same
tween engine reading the same clock. The timeline's length is pinned to 1, so every beat
position in the source is written as raw scroll progress.
src/
├── animations/
│ ├── cameraAnimations.js # camera beats and focus-light beats
│ ├── scrollTimeline.js # the master timeline
│ └── uiAnimations.js # intro, counters, scroll-to
├── audio/
│ └── AudioManager.js # synthesised soundtrack
├── components/
│ ├── Annotations.jsx # labels projected from 3D
│ ├── CTA.jsx # closing section and colophon
│ ├── Engineering.jsx # Foundation, Performance, Architecture
│ ├── Hero.jsx # opening frame
│ ├── Interior.jsx # cabin
│ ├── Loader.jsx # loading veil
│ ├── Navigation.jsx # masthead, telemetry, rail
│ ├── Panel.jsx # shared section wrapper
│ ├── SoundControl.jsx # mute toggle
│ └── Technology.jsx # Powertrain, Technology
├── styles/
│ ├── global.css # palette, type, stage
│ ├── loader.css
│ ├── navigation.css
│ └── sections.css # panels, scrims, stats, annotations
├── three/
│ ├── Camera.js # CameraRig: chase, drift, parallax, framing
│ ├── Lighting.js # PMREM studio, key/rim/fill, contact shadow
│ ├── Materials.js # material overrides
│ ├── Scene.js # Experience: renderer, loop, quality tier
│ ├── Vehicle.js # GLB load and explode mechanics
│ └── VehicleParts.js # explode rules and annotations
├── App.jsx
└── main.jsx
| Progress | Section | Scene |
|---|---|---|
0.00 – 0.12 |
Hero | Front three-quarter, long lens |
0.17 – 0.26 |
Foundation | Front wheel at hub height, rim leaving the disc |
0.29 – 0.38 |
Performance | Side elevation, figures count up |
0.41 – 0.50 |
Architecture | Fully exploded, camera high and wide |
0.53 – 0.62 |
Interior | Down into the open cockpit |
0.64 – 0.73 |
Powertrain | Engine drawn forward out of the bay |
0.75 – 0.86 |
Technology | Rear quarter, aerodynamics |
0.91 → ∞ |
CTA | The car reassembles |
The source asset parents every part under the chassis mesh, which means moving the chassis
moves the entire car and the chassis can never move on its own. On load, every direct child
is re-attached to a neutral root — attach preserves world transforms — leaving the chassis
as a sibling of the parts it used to own. Nested sub-parts stay put, so a door still carries
its own glass, mirror and door card.
Displacements are authored as a table in VehicleParts.js, not improvised at runtime:
door: { out: 0.98, up: 0.12, fwd: 0, rot: [0, 0.10, 0] }
hood: { out: 0, up: 0.88, fwd: 0.64, rot: [-0.10, 0, 0] }
pillars: { out: 0, up: 1.40, fwd: 0 }
engine: { out: 0, up: 0.64, fwd: 1.12 }out is signed by the part's own side, so one rule serves both flanks. The roof stack is
ordered pillars → glass → roof so the three never interpenetrate in mid-air, and the pillars
clear the cabin entirely — the A-pillar rails run down to the sill, so anything under about
1.15 leaves them standing between the seats when the interior beat looks in.
The whole assembly also floats to lift while it is apart, which is what gives the sills
and the valance somewhere to drop to.
Scroll never moves the camera. Scroll moves a target, and the rig chases it with damped interpolation — that single indirection is what keeps the motion from feeling robotic. The damping is expressed in e-folds per second, so the feel is identical at 30, 60 or 120 fps.
On top of the chase sit a slow orbital drift and mouse parallax, both deliberately under-scaled and both falling away as the camera closes in.
Each beat carries two framing channels beyond its position:
rise— the fraction of the vehicle's currentliftthe camera travels with. Beat positions are authored against the car at rest, so without this a close beat frames the air a part has left behind. Expressed as a fraction rather than baked into the position because the lift is scaled down on small screens.- portrait framing —
fovis the vertical field, so a tall viewport keeps the top and bottom of the desktop frame and throws away the sides, which is exactly where these compositions live. The rig opens the lens a little, dollies back about the beat's own look point, and drops the look point so the vehicle rides above the copy. All three are no-ops at 16:9 and wider.
There is no outline shader and no dimming of the rest of the scene. Highlighting is a small champagne point light placed at the component under discussion, fading up on arrival and down on leaving — it moves while dark, so it never streaks across the vehicle.
Labels are projected from 3D every frame and written straight to transform, so they stay
attached to a part as it flies out. Visibility is read from scroll progress rather than
tweened, which makes scrubbing backwards exactly as correct as scrubbing forwards.
The soundtrack is synthesised in-browser. No audio files are downloaded, and it never loops audibly. Five layers over an Fmaj9: bass, pad, room air, sparse struck notes, and a mechanical texture gated by how far apart the car currently is.
Gain staging is written down in the source, because a bed that sums through one limiter is easy to leave 40 dB too quiet:
bass 0.30 + pad 0.18 + keys 0.16 + air 0.02 ≈ 0.66 peak
→ limiter (-14 dB, 4:1) → master 0.55 → ≈ -12 dBFS
Autostart. The music comes up on its own, fading in over 5.5 s. Browsers will not allow audio before the visitor has interacted with the page, so the attempt is made immediately, the context state is checked rather than trusted, and a one-shot listener on the first interaction — including the scroll that drives the film — picks it up otherwise. In practice the soundtrack arrives as the visitor starts moving, with nothing to press. A deliberate mute outranks autostart for the rest of the visit.
Measured at 1280×720, hero frame, via renderer.info:
| Draw calls | Triangles / frame | |
|---|---|---|
| Before | 220 | 426,698 |
| After | 116 | 223,239 |
The two changes behind that:
- The shadow map is refreshed on demand. The key light never moves and nothing casts a
moving shadow unless a part is actually travelling, so
shadowMap.autoUpdateis off andVehicle.updatereports whether any geometry moved. Previously the entire scene was re-drawn into the shadow map on every frame where only the camera had changed — which is most of them. - Frustum culling is on. It had been disabled on the theory that parts leave the body's
bounds when exploded, but Three.js tests each mesh against its own bounding sphere
transformed by its own
matrixWorld, so a part that flies out carries its bounds with it.
Also: materials fall back to MeshStandardMaterial unless they actually ask for a physical
feature — only the two paint materials need clearcoat — and the bundle is split so three
caches independently of the app.
| Chunk | Size | gzip |
|---|---|---|
three |
625 kB | 158 kB |
index |
234 kB | 75 kB |
motion |
116 kB | 46 kB |
The GLB itself is 2.9 MB, down from 11.78 MB, via dedup, prune, WebP textures and
meshopt compression — with the node hierarchy and all 97 meshes preserved.
Desktop is the primary experience; mobile keeps the full cinematic scene rather than falling back to something else. The quality tier is recomputed on every resize, so a desktop window dragged to phone width actually crosses over:
| Desktop | Mobile | |
|---|---|---|
| Pixel ratio cap | 1.75 | 1.5 |
| Shadow map | 2048 | 1024 |
| Antialias | on | off |
| Explode throw | 1.0 | 0.72 |
| Idle camera motion | 1.0 | 0.5 |
Layout follows: the section list gives way to the telemetry and rail, copy moves to the base of the frame with the scrim rotating to match, annotations keep their figures and drop their captions.
prefers-reduced-motion is honoured throughout: the intro resolves without wiping, the
scroll timeline locks to the scrollbar instead of scrubbing with weight, and the camera's
idle drift and breathing are switched off — they are exactly the unrequested movement that
setting is about. Sound is never forced on a visitor who has muted it.
In dev builds the film exposes a seek hook, so a headless browser can jump to an exact progress without waiting out the scrub and the camera damping:
window.__nova.seek(0.57) // jump to the interior beat
window.__nova.experience // renderer, rig, vehicle, stateVehicle model derived from Car Concept by Eric Chadwick, Darmstadt Graphics Group, licensed CC BY 4.0. Materials, lighting, choreography and sound are original. The source asset's Khronos branding is removed rather than redistributed.
Full detail, including the trademark handling, is in ATTRIBUTION.md.