Three.js Scroll City Flyover — GSAP Night Skyline Camera
Three.js Scroll City Flyover · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
How to Build a Scroll-Driven Night City Flyover With Three.js and GSAP

The Three.js Scroll City Flyover snippet takes the visitor from street level up over a low-poly night skyline as they scroll, banking and climbing the whole way, by pairing dozens of instanced-style THREE.BoxGeometry buildings with a single scrubbed progress value driven by GSAP's ScrollTrigger. Unlike the scroll tunnel, which threads a fixed curve, this scene is an open 3D city where the camera's start and end positions are simply two points that get interpolated and reshaped as scroll progress changes.
Buildings as boxes, not models
Every building is a plain THREE.BoxGeometry with randomized width, depth, and height, positioned in loosely staggered "blocks" that step backward along Z. There is no imported 3D model and no GLTF loader — the low-poly aesthetic is a deliberate choice, not a limitation, because flat-shaded boxes read clearly as a skyline silhouette from a distance while staying cheap enough to instantiate two hundred-plus of them without a frame hitch. Twenty-six blocks spaced ninety units apart give the camera a runway long enough that structures are always visible ahead, no matter how far the scroll has progressed.
Windows from a canvas texture, not an image file
Rather than shipping a window sprite as a static asset, the snippet draws one procedurally with the 2D Canvas API: a 128×128 grid of cells, each randomly left dark or filled with a warm or cool lit square, wrapped into a THREE.CanvasTexture with RepeatWrapping. Generating it in code means zero network requests, an easy knob to change the lit-window ratio or color balance, and a texture that tiles seamlessly across buildings of any size by adjusting repeat per mesh. A second, slightly larger box wraps each building body using this texture on an unlit MeshBasicMaterial, so the windows glow independent of scene lighting rather than needing an emissive map baked per building.
Two-mesh buildings instead of a custom shader
Each building is actually two overlapping meshes: an opaque MeshStandardMaterial body that receives the hemisphere and directional "moon" light, and a transparent window shell rendered just outside it. This sidesteps writing a custom shader to mix a lit body color with emissive window squares — two draw calls per building is a simple, debuggable trade that still reads correctly from any camera angle because the shell is only a fraction of a percent larger than the body.
One progress value drives climb, drift, and look target
A single flight.t value scrubs from 0 to 1 across the pinned stage, exactly like the pattern used in the scroll tunnel and scroll camera path snippets. Position is a straight lerpVectors between a low street-level start and a high rooftop-clearing end, but the vertical component is additionally smoothstepped through a separate climb curve so the rise reads as a takeoff arc rather than a diagonal line. A sine-based sideways drift, scaled by that same climb value, adds a gentle bank that only kicks in once the camera has real altitude to bank at.
Fog and background color locked together
FogExp2 uses the identical hex value as scene.background, the same technique as the tunnel snippet, so the far end of the city dissolves into open sky rather than hitting a visible draw-distance wall. This is what makes twenty-six blocks feel like an endless metropolis instead of a finite diorama with an edge.
Light streaks recycle instead of respawn
Sixty points drift past the camera to sell speed at altitude, where the buildings alone cross the frame too slowly to convey motion once the camera is high above the rooftops. Each frame nudges every streak's Z forward by a fixed amount and wraps it back by cityDepth once it passes the camera, so the same sixty-point BufferGeometry never needs particles added or removed — only one small typed-array write per frame. Pair this with a digital grid pulse HUD overlay or a starfield warp intro section for a fuller "descending into the city" scroll sequence.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You do not need to piece together how a whole night skyline flyover works from scratch. Paste this snippet's HTML, CSS, and JS into an AI assistant like Claude and ask it to explain why the window texture is generated on a canvas instead of loaded as an image, or why buildings use two overlapping meshes rather than one shader-driven material. The same assistant can help you extend the scene, for instance adding blinking window lights over time, spawning a helicopter searchlight cone that sweeps as the camera passes, or merging the building bodies into an InstancedMesh for better performance on mobile. It can also help you retune the flight itself, like adding a banking turn around a specific landmark building at a chosen scroll percentage. Treat the code as a working draft to interrogate and reshape, not a finished, untouchable artifact.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll-scrubbed night city flyover" in plain HTML, CSS, and JavaScript using Three.js and GSAP's ScrollTrigger plugin, all loaded from a CDN (no bundler, no build step).
Requirements:
- A pinned section containing a full-size canvas, with a WebGLRenderer and PerspectiveCamera sized to it and updated on window resize including aspect ratio.
- Generate a procedural window texture at runtime using the 2D Canvas API (a grid of randomly lit warm/cool squares on a dark background), wrap it in a THREE.CanvasTexture with RepeatWrapping, and reuse it across all buildings.
- Populate a scene with 150-250 THREE.BoxGeometry buildings of randomized width, depth, and height, arranged in staggered blocks stepping backward along the Z axis, each built from two overlapping meshes: an opaque lit body and a transparent unlit window shell using the canvas texture with a per-mesh repeat value.
- Add a large ground plane and a night-sky background color, with THREE.FogExp2 using that identical color so the far end of the city fades into open sky.
- Add a small particle field (THREE.Points) of light streaks that drift past the camera and wrap around using modular arithmetic on their Z position instead of being respawned.
- Register a GSAP tween on a ScrollTrigger targeting the pinned section, with pin: true, start at top top, a numeric scrub around 0.8, and an end several hundred percent tall, animating a single plain value t from 0 to 1.
- Every animation frame (requestAnimationFrame, independent of the scroll callback), lerp camera position between a low street-level start point and a high rooftop-clearing end point using t, but pass a separately smoothstepped version of t through the vertical (Y) component so climbing reads as a takeoff arc, and add a sine-based sideways drift and matching Z-axis roll scaled by that same climb amount.
- Confirm scrolling back up reverses the entire flyover, since t is fully scrubbed rather than a one-way timer.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Load all three CDN scriptsAdd three.min.js, gsap.min.js, and ScrollTrigger.min.js from the CDN panel, in that order, before the snippet JS.
- 2Paste HTML, CSS, and JSA pinned night skyline appears with a live altitude read-out in the bottom-left HUD.
- 3Scroll downThe camera climbs from street level, banking gently as it rises and flies forward over the rooftops.
- 4Scroll back upThe flyover reverses exactly since flight.t is fully scrubbed, not a one-shot timer.
- 5Retune the skylineAdjust BLOCK_COUNT, BLOCK_SPACING, or the width/depth/height random ranges in addBuilding calls to change city density.
- 6Change the flight pathEdit startPos and endPos, or the climb smoothstep curve, to fly lower, higher, or in a different direction.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Drawing the window grid with the 2D Canvas API means no network request, no asset licensing to worry about, and an easy code-level knob for lit-window ratio, cell size, and warm/cool color mix. Because the result is a THREE.CanvasTexture with RepeatWrapping, the same texture object tiles cleanly across buildings of wildly different sizes just by adjusting each mesh material's repeat value — an image asset would need careful UV unwrapping per box to avoid stretching.
A single custom material mixing a solid body color with emissive window squares would require either a hand-written shader or a baked texture unique to each building size. Layering a transparent, unlit window shell just outside an opaque lit body achieves the same visual result with two stock materials, keeping the code readable and easy to retune independent of scene lighting.
Position is linearly interpolated between a low start and a high end point, but the vertical component runs through a separate smoothstep curve (climb) so height rises slower at first and levels off near the top, reading as a takeoff arc. That same climb value scales a sine-based sideways drift and a matching camera.rotation.z roll, so banking only appears once the camera actually has altitude to bank at, rather than rolling while still low among the streets.
Flat BoxGeometry meshes are extremely cheap to rasterize and there is no per-frame geometry change, only per-frame camera and particle updates, so the scene holds a steady frame rate on modest hardware. For very large cities, merging the body meshes into a single THREE.InstancedMesh per material would cut draw calls further, the same optimization suggested for the ring meshes in the scroll tunnel snippet.
Yes. Click JSX for a React component, Vue for a Vue 3 SFC, Angular for a standalone component, or Tailwind for a React + Tailwind version. Build the scene, buildings, and GSAP timeline inside a mount effect against a canvas ref, and on cleanup kill the ScrollTrigger instance (or revert a gsap.context), dispose of the CanvasTexture and geometries, and call renderer.dispose() so WebGL resources and the scroll pin are released on unmount.