Hello, my title is Houmahani Kane. I’m a artistic front-end developer primarily based 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 may 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: pictures stacked alongside the Z-axis, every carrying its personal colour palette that shifts the background as you progress via them. I needed it to be much less like a slideshow and extra like a stroll via a temper. You may reuse this sample for product collections, campaigns, supplies, or any sequence of pictures you need 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 quite than a format.
- 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 velocity turns into a reusable sign that may subtly elevate or calm the scene and information you thru it.
The code snippets assume familiarity with Three.js and GLSL, however the ideas and method are for everybody. I’m utilizing Vite with Three.js and plain JavaScript. Snippets are simplified for readability, please seek advice from the supply code for full implementation.
The inspiration
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 digicam, one render loop. All the things else comes on high of this.
I work in a class-based construction the place every file has a single accountability and stays beneath just a few hundred traces so it stays constant, readable, and sort to future me.
src
┣ Expertise
┃ ┣ Background/
┃ ┣ Engine.js
┃ ┣ Gallery.js
┃ ┣ Scroll.js
┃ ┗ index.js
┣ knowledge/
┗ major.js
Engine owns the scene, Gallery owns the planes, Scroll owns the digicam motion, Background owns the temper.
Into the depth
Objective: scroll via planes stacked in 3D house.
That is the place the gallery stops being flat and begins feeling spatial, such as you’re really shifting via one thing quite than swiping previous it. No pictures but, simply the structure of depth.
Putting 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
)
})
- Detrimental Z pushes every aircraft deeper into the scene.
- planeGap controls the spacing.
- X positions come from
galleryDataso the composition stays intentional.

Scroll drives the digicam
That is what makes the gallery really feel responsive. Scroll enter turns into digicam motion via Z house. The smoother this feels, the extra the gallery looks like a bodily house you’re shifting via.
The secret is separating what the consumer does from what the digicam does. Uncooked scroll enter is messy, so we easy it earlier than making use of it.
scrollTarget += wheelDelta // uncooked enter from wheel/contact
scrollCurrent = lerp(scrollCurrent, scrollTarget, scrollSmoothing) // smoothed worth
digicam.place.z = cameraStartZ - scrollCurrent * scrollToWorldFactor // digicam strikes in depth
scrollSmoothingcontrols how lazily the digicam follows. The nearer to 0, the extra cinematic it feels.scrollToWorldFactorconverts pixel scroll enter into 3D world motion. With out it, the digicam would journey method too far.
Including bounds
With out bounds, the digicam 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 are able to add or take away pictures and bounds replace routinely.- We clamp each
scrollTargetandscrollCurrent. Clamping just one would let the digicam briefly drift previous the boundary. firstPlaneViewOffsetandlastPlaneViewOffsetmaintain a small distance so the digicam by no means enters the planes
Making it really feel alive
Objective: measure how briskly the consumer is shifting via the gallery, not simply the place they’re.
Proper now the scene doesn’t react to how you progress via it. It reacts the identical method whether or not you scroll slowly or rush via it. This step captures how briskly or sluggish the consumer is scrolling and turns it right into a sign we’ll reuse all over the 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 velocity
this.rawVelocity = this.scrollCurrent - this.previousScrollCurrent
// easy 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
// digicam 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 edge resets velocity to precisely 0 when the consumer stops. It avoids tiny undesirable flickering.
The temper system
Objective: actual pictures, background colours shifting with them, ambiance 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 pictures
We change flat colours with actual pictures. The gallery knowledge appears 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 may drive your entire background.
- Airplane dimension follows the picture side ratio, so nothing appears 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 via depth, the ambiance shifts from one picture’s palette to the following. No exhausting 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 digicam 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 via it, and we determine its ultimate colour: background, blobs, grain, brightness response. All of it lives right here.
The shader is deliberately minimal. I attempted extra complicated techniques like high/mid/backside colours, further accents, however they have been tougher to debug and pointless. GLSL is already complicated 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 mushy 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 via the gallery.
This ultimate layer is what makes the demo really feel usable quite than purely technical. It could possibly be a product marketing campaign, a fabric assortment, a visible archive. The construction is already there.
Including an editorial label layer
A gallery of pictures is a portfolio. Photographs with textual content, colour knowledge, and intentional composition is a narrative. That’s the distinction this layer makes.
The colour chip, exhibiting Hex, RGB, CMYK and PMS values, was impressed by an Instagram put up by @thisislandscape that I stored 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 data feels significant: a product title, 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. All the things solutions to the identical worth, which is what makes the system really feel coherent quite than assembled.
Movement and breath
Now we have 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 heart. It really works greatest on a trackpad and offers the entire thing a bodily, tactile high quality.
- Scroll velocity drives a breath. The quicker 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 velocity 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 via 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 via the gallery
x = sin(progress × 2π × horizontalCycles) × width // left/proper oscillation
y = sin(progress × 2π × verticalCycles) × verticalAmplitude // up/down oscillation
z = cameraZ + distanceAhead // at all times forward of the digicam
The curve the place factors are handed right into a Three.js Catmull-Rom spline for easy interpolation:
// 'centripetal' is the Three.js curve sort that handles sharp turns greatest
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 quite 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 complicated so I leaned on AI to work via elements of it.
Go additional
Listed here are just a few instructions you may discover from right here:
- Change the flower pictures 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, mild flares on quick scroll. There’s loads of room.
- And audio. A soundscape that reacts to depth and movement would take the immersion to a very completely different degree. That one’s on my listing.
Conclusion
At this level every part 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 end result feels easy and cohesive. All the things serves the identical thought: making scroll really feel like one thing you expertise, not simply one thing you do.


