Hello, my identify is Houmahani Kane. I’m a artistic front-end developer based mostly in Paris, France.
This demo began as an experiment: can scroll really feel like time passing? An earlier model was a cosmic path drifting from nightfall to nighttime. You possibly can see it right here. For Codrops, I reworked the concept into one thing extra editorial: a gallery that designers and artwork administrators might join with as a lot as builders.
What we’re constructing is a depth-based gallery: photos stacked alongside the Z-axis, every carrying its personal colour palette that shifts the background as you progress by them. I wished it to be much less like a slideshow and extra like a stroll by a temper. You possibly can reuse this sample for product collections, campaigns, supplies, or any sequence of photos you wish to flip right into a small story.
Idea
The entire system relies on three concepts. Independently they’re easy however collectively they’re what makes the gallery really feel like a temper somewhat than a structure.
- Depth: every picture lives by itself Z-layer as an alternative of a flat carousel.
- Temper: each picture defines a palette that drives the background gradient.
- Movement: scroll pace turns into a reusable sign that may subtly raise or calm the scene and information you thru it.
The code snippets assume familiarity with Three.js and GLSL, however the ideas and strategy are for everybody. I’m utilizing Vite with Three.js and plain JavaScript. Snippets are simplified for readability, please discuss with the supply code for full implementation.
The muse
Objective: get a single aircraft rendering on display.
Earlier than any temper or movement, there’s only a clean canvas. This step is about making the scene exist with one aircraft, one digital camera, one render loop. The whole lot else comes on high of this.
I work in a class-based construction the place every file has a single accountability and stays underneath a couple of hundred traces so it stays constant, readable, and sort to future me.
src
┣ Expertise
┃ ┣ Background/
┃ ┣ Engine.js
┃ ┣ Gallery.js
┃ ┣ Scroll.js
┃ ┗ index.js
┣ information/
┗ primary.js
Engine owns the scene, Gallery owns the planes, Scroll owns the digital camera motion, Background owns the temper.
Into the depth
Objective: scroll by planes stacked in 3D house.
That is the place the gallery stops being flat and begins feeling spatial, such as you’re truly transferring by one thing somewhat than swiping previous it. No photos but, simply the structure of depth.
Inserting the planes
We begin easy: 6 placeholder planes, flat colours, right spacing. We get the construction proper earlier than including every other complexity.
const planeGap = 2.5 // distance between every aircraft in world house
planes.forEach((aircraft, index) => {
aircraft.place.set(
planePositions[index].x, // horizontal composition from galleryData
0, // y — vertically centered
-index * planeGap // z — pushes every aircraft deeper into the scene
)
})
- Adverse Z pushes every aircraft deeper into the scene.
- planeGap controls the spacing.
- X positions come from
galleryDataso the composition stays intentional.

Scroll drives the digital camera
That is what makes the gallery really feel responsive. Scroll enter turns into digital camera motion by Z house. The smoother this feels, the extra the gallery looks like a bodily house you’re transferring by.
The bottom line is separating what the consumer does from what the digital camera does. Uncooked scroll enter is messy, so we clean it earlier than making use of it.
scrollTarget += wheelDelta // uncooked enter from wheel/contact
scrollCurrent = lerp(scrollCurrent, scrollTarget, scrollSmoothing) // smoothed worth
digital camera.place.z = cameraStartZ - scrollCurrent * scrollToWorldFactor // digital camera strikes in depth
scrollSmoothingcontrols how lazily the digital camera follows. The nearer to 0, the extra cinematic it feels.scrollToWorldFactorconverts pixel scroll enter into 3D world motion. With out it, the digital camera would journey manner too far.
Including bounds
With out bounds, the digital camera drifts previous the final aircraft into empty house. This step retains the expertise contained. The gallery has a starting and an finish.
// get the Z vary of all planes
const { nearestZ, deepestZ } = this.gallery.getDepthRange()
// convert depth vary to scroll limits
const minScroll = this.scrollFromCameraZ(nearestZ + this.firstPlaneViewOffset)
const maxScroll = this.scrollFromCameraZ(deepestZ + this.lastPlaneViewOffset)
// clamp each — clamping just one would let smoothing overshoot
this.scrollTarget = THREE.MathUtils.clamp(this.scrollTarget, minScroll, maxScroll)
this.scrollCurrent = THREE.MathUtils.clamp(this.scrollCurrent, minScroll, maxScroll)
getDepthRange()reads all aircraft Z positions. We will add or take away photos and bounds replace robotically.- We clamp each
scrollTargetandscrollCurrent. Clamping just one would let the digital camera briefly drift previous the boundary. firstPlaneViewOffsetandlastPlaneViewOffsetmaintain a small distance so the digital camera by no means enters the planes
Making it really feel alive
Objective: measure how briskly the consumer is transferring by the gallery, not simply the place they’re.
Proper now the scene doesn’t react to how you progress by it. It reacts the identical manner whether or not you scroll slowly or rush by it. This step captures how briskly or gradual the consumer is scrolling and turns it right into a sign we’ll reuse in every single place: background, movement, breath. It’s what makes the entire thing really feel prefer it’s responding to you.
Earlier than wiring it to something visible, I constructed the debug visualizer first. At this stage velocity has no visible output but, so with out the bar we’re tweaking the values blind.
Press D on the demo to see the debug instruments
Conceptually:
- Quick scroll → excessive velocity
- Gradual scroll / cease → velocity easily goes again to zero
// delta between this body and final = uncooked pace
this.rawVelocity = this.scrollCurrent - this.previousScrollCurrent
// clean it so it would not flicker body to border
this.velocity = THREE.MathUtils.lerp(this.velocity, this.rawVelocity, this.velocityDamping)
// maintain it in a protected vary
this.velocity = THREE.MathUtils.clamp(this.velocity, -this.velocityMax, this.velocityMax)
// resets to precisely 0 when the consumer stops — avoids tiny undesirable flickering
if (Math.abs(this.velocity) < this.velocityStopThreshold) this.velocity = 0
replace() {
// scroll smoothing + clamping...
this.updateVelocity()
this.updateVelocityVisualizer() // take away in manufacturing
// digital camera replace...
}
What every half does:
rawVelocityis the distinction between this body and the final.velocityDampingcontrols how lengthy the “breath” lasts after the consumer stops.- The brink resets velocity to precisely 0 when the consumer stops. It avoids tiny undesirable flickering.
The temper system
Objective: actual photos, background colours shifting with them, environment that responds to motion.
That is the place the gallery begins feeling intentional. Up till now, it was spatial however chilly. This step is what makes it really feel like a temper.
Swapping in actual photos
We change flat colours with actual photos. The gallery information seems like this:
export const galleryData = [
{
textureSrc: '/image-01.jpg',
position: { x: -1.2 },
mood: {
background: '#fbe8cd', // background color
blob1: '#ffd56d', // first atmosphere blob
blob2: '#5d816a', // second atmosphere blob
},
},
]
- Every picture carries its personal temper palette. Three colours that can drive all the background.
- Airplane dimension follows the picture side ratio, so nothing seems stretched.
Temper per picture and mix by depth
The rate sign we captured within the earlier part now has its first job: driving the background. As you progress by depth, the environment shifts from one picture’s palette to the subsequent. No onerous cuts, only a steady mix.
this.backgroundColor
.set(currentMood.background)
.lerp(this.nextBackgroundColor.set(nextMood.background), mix)
this.blob1Color
.set(currentMood.blob1)
.lerp(this.nextBlob1Color.set(nextMood.blob1), mix)
mixis a 0 to 1 worth computed from digital camera place between two planes.- Each body the background interpolates between the present and subsequent picture’s palette.
Shader background
The fragment shader is the place the magic occurs. Consider it as a painter’s palette. Each pixel on display passes by it, and we determine its remaining colour: background, blobs, grain, brightness response. All of it lives right here.
The shader is deliberately minimal. I attempted extra advanced methods like high/mid/backside colours, additional accents, however they have been more durable to debug and pointless. GLSL is already advanced sufficient.
Two blobs, one background colour, a contact of movie grain. It doesn’t want greater than that.
// 1. flat background
vec3 colour = uBgColor;
// 2. two gentle blobs
float blob1 = smoothstep(uBlobRadius, 0.0, distance(vUv, blob1Center)); // positions animated with uTime
float blob2 = smoothstep(uBlobRadiusSecondary, 0.0, distance(vUv, blob2Center)); // positions animated with uTime
// 3. mix blobs into background
vec3 blob1SoftColor = combine(uBlob1Color, uBgColor, 0.35);
vec3 blob2SoftColor = combine(uBlob2Color, uBgColor, 0.35);
colour = combine(colour, blob1SoftColor, blob1 * uBlobStrength);
colour = combine(colour, blob2SoftColor, blob2 * uBlobStrength);
// 4. velocity lifts brightness barely on quick scroll
colour += uVelocityIntensity * 0.10;
// 5. movie grain for texture
float grain = random(vUv * vec2(1387.13, 947.91)) - 0.5;
colour += grain * uNoiseStrength;
gl_FragColor = vec4(colour, 1.0);
The ending touches
Objective: layer in editorial textual content, micro-motion pushed by velocity, and a path that strikes like wind by the gallery.
This remaining layer is what makes the demo really feel usable somewhat than purely technical. It may very well be a product marketing campaign, a fabric assortment, a visible archive. The construction is already there.
Including an editorial label layer
A gallery of photos is a portfolio. Photos with textual content, colour information, and intentional composition is a narrative. That’s the distinction this layer makes.
The colour chip, displaying Hex, RGB, CMYK and PMS values, was impressed by an Instagram put up by @thisislandscape that I saved coming again to. It felt just like the sort of element that speaks to designers and artwork administrators. Full credit score to them.
Designers can swap this layer for no matter info feels significant: a product identify, a marketing campaign chapter, a fabric reference. The system doesn’t care.
label: {
phrase: 'violet',
line: 'pressed bloom',
pms: 'PMS 7585 C',
colour: '#2e2e2e',
},
Depth was already driving the planes and the temper, so it made sense for the textual content to observe the identical development. The whole lot solutions to the identical worth, which is what makes the system really feel coherent somewhat than assembled.
Movement and breath
We have now three layers of micro-motion, every pushed by a special sign:
- Mouse place creates a refined X/Y parallax on every aircraft, a quiet reminder that you just’re in a 3D house.
- Scroll drift is my favorite element. As you swipe up on a trackpad or scroll with a mouse, the planes drift upward with you want they’ve weight. As in the event that they’re responding to your contact. Swipe down, they observe. Cease, they lazily float again to middle. It really works finest on a trackpad and provides the entire thing a bodily, tactile high quality.
- Scroll pace drives a breath. The sooner you scroll, the extra the planes tilt towards your cursor and pulse barely in scale. At relaxation: flat and nonetheless. Throughout quick scroll: tilted and alive.
In brief: parallax = the place your mouse is, scroll drift = which path you’re scrolling, breath = how briskly.
// 1. parallax — mouse place shifts planes
// parallaxInfluence: deeper planes shift extra (opacity * depth issue)
aircraft.place.x = xPosition + pointerX * parallaxAmount * parallaxInfluence
aircraft.place.y = yPosition + pointerY * parallaxAmount * parallaxInfluence
// 2. scroll drift — planes observe your gesture path
driftTarget = scrollDrift // -1 to +1
aircraft.place.y += driftCurrent * driftAmount
// 3. breath — scroll pace tilts and pulses planes
aircraft.rotation.x = pointerY * breathTilt * breathIntensity
scalePulse = 1 + breathScale * breathIntensity
The path
With the depth, the temper shifts and the movement already in place, I felt the scene was full. Including extra risked breaking the expertise.
In my authentic cosmic expertise, I did have a path guiding the consumer. So I pushed myself to discover a model that felt proper for this editorial model. One thing that strikes like wind by the gallery of flowers, tracing elegant curves as you scroll, guiding you ahead with out demanding an excessive amount of consideration.
Three issues work collectively:
The trail pushed by scroll progress:
// the path winds throughout display house as you scroll by the gallery
x = sin(progress × 2π × horizontalCycles) × width // left/proper oscillation
y = sin(progress × 2π × verticalCycles) × verticalAmplitude // up/down oscillation
z = cameraZ + distanceAhead // all the time forward of the digital camera
The curve the place factors are handed right into a Three.js Catmull-Rom spline for clean interpolation:
// 'centripetal' is the Three.js curve kind that handles sharp turns finest
const curve = new THREE.CatmullRomCurve3(factors, false, 'centripetal')
const sampled = curve.getSpacedPoints(segments) // evenly spaced factors alongside the curve
The geometry, a tube rebuilt each body, fats on the head and getting thinner towards the tail:
// t goes from 0 (head) to 1 (tail)
// energy curve makes the taper really feel pure somewhat than linear
radius = radiusHead + (radiusTail - radiusHead) * Math.pow(t, 1.5)
I’ve added sparkle particles on the head to finish the impact. The mathematics right here was advanced so I leaned on AI to work by components of it.
Go additional
Listed below are a couple of instructions you may discover from right here:
- Change the flower photos for product collections, marketing campaign chapters, archives, or supplies. The depth and temper system doesn’t care what the photographs are. It simply wants a palette per picture. That’s it.
- Velocity is the sign I’m most excited to push additional. We barely scratched the floor right here. We might have distortion on the planes, depth-of-field shifts, gentle flares on quick scroll. There’s quite a lot of room.
- And audio. A soundscape that reacts to depth and movement would take the immersion to a very completely different stage. That one’s on my record.
Conclusion
At this level every little thing is linked: depth drives the planes, the temper, and the textual content. Velocity makes the scene breathe. The background blends seamlessly between atmospheres as you progress.
The outcome feels clean and cohesive. The whole lot serves the identical thought: making scroll really feel like one thing you expertise, not simply one thing you do.









