Product grids are the white-box gallery of e-commerce — impartial by default, inoffensive by design. Which is unusual, as a result of the bodily experiences that really transfer product have at all times recognized that atmosphere is a part of the promote. Lighting makes selections. Association communicates worth. The house has a standpoint.
The online model normally opts out of all of that.
I needed to see what it could take to shut that hole — not as a novelty, however as a real try to make searching really feel like being someplace. This text walks by means of how I constructed it: a curved 3D product grid utilizing React Three Fiber, a topographic GLSL background, holographic choice states, and a spring-damped digicam rig. Alongside the way in which there are some patterns value stealing — round shader structure, animation interruptibility, and the place to attract the road between React state and mutable refs.
The Stack
The venture runs on Subsequent.js, React Three Fiber, Tailwind, and Movement. The 2 customized shaders are written in GLSL and imported as ES modules by means of a glslify webpack pipeline.
The glslify setup is value calling out as a result of it’s the one piece of infrastructure that makes shader growth really feel fashionable. A two-loader chain in subsequent.config.mjs lets us write #pragma glslify: snoise = require('glsl-noise/simplex/2nd') inside our GLSL and import the compiled end result as a string.
Structure
The system has 4 layers, and understanding the place every one begins and stops is what retains the venture clear:
┌─────────────────────────────────────────────────┐
│ DOM Layer (Framer Movement) │
│ Management bar, filters, minimap, overlays │
├─────────────────────────────────────────────────┤
│ Scene Layer (React Three Fiber) │
│ Canvas, digicam rig, lighting │
├─────────────────────────────────────────────────┤
│ Tile Layer (per-card useFrame loops) │
│ Place, scale, opacity, shader uniforms │
├─────────────────────────────────────────────────┤
│ Shader Layer (uncooked GLSL) │
│ Topography background, holographic card sheen │
└─────────────────────────────────────────────────┘
Knowledge circulate. Shoe knowledge is a JSON array. Every assortment (Nike, New Steadiness, Price range) maps to a separate array. Filters slim inside a set; assortment switches swap all the array.
Interplay loop. Pointer occasions on the canvas replace a mutable rigState object. The digicam rig reads that each body and damps towards the goal. Every tile reads the identical rigState to know whether it is chosen, then adjusts its personal place, scale, and shader uniforms.
The choice that formed every little thing was what to place in React state versus mutable refs. I discovered this the onerous manner: something that adjustments at 60fps — digicam place, tile animation progress, shader uniforms — can not reside in React state. The reconciliation overhead kills you. These values reside in plain mutable objects that useFrame callbacks learn immediately. React state is reserved for discrete person actions: which assortment is lively, which filters are set, which tile is chosen.
The Grid
The primary drawback was format. I wanted to take a flat record of footwear and organize them in a centered grid in 3D house, with sufficient flexibility to help filtering (which adjustments the merchandise depend) and assortment switching (which adjustments every little thing).
Configuration
All grid parameters reside in a mutable singleton — not React state, not context, only a plain object:
const CONFIG = {
gridCols: 8,
itemSize: 2.5,
hole: 0.4,
zoomIn: 12,
zoomOut: 31,
curvatureStrength: 0.06,
dampFactor: 0.2,
tiltFactor: 0.08,
cullDistance: 14,
};
I wired each worth into Leva debug controls throughout growth. Dragging a “curvature” slider and watching the grid bowl deepen in actual time was invaluable for dialing in really feel — one thing you can’t do with hardcoded constants and a refresh cycle.
Positioning
Tile positions come from easy column-major math, centered on the origin:
const spacing = CONFIG.itemSize + CONFIG.hole;
const col = filteredIdx % CONFIG.gridCols;
const row = Math.flooring(filteredIdx / CONFIG.gridCols);
const x = col * spacing - gridWidth / 2 + spacing / 2;
const y = -(row * spacing) + gridHeight / 2 - spacing / 2;
X runs left-to-right. Y runs top-to-bottom. Z is fully reserved for depth results — curvature, focus, and transition animations. Retaining Z free turned out to be one of many higher early selections, as a result of it meant I might layer a number of depth results additively with out them combating one another.
The Playing cards
Every shoe is a ShoeTile — a <group> containing a hit-test aircraft, a picture mesh with our customized shader materials, textual content labels, and an in depth button.
Textures
I preload each texture at module stage earlier than any element mounts. This was non-negotiable — with out it, switching collections brought about seen pop-in as textures uploaded to the GPU one after the other:
footwear.forEach((shoe) => {
useTexture.preload(shoe.image_url);
});
Every tile computes aspect-correct dimensions from the loaded texture so photographs are by no means stretched.
The Animation Loop
That is the guts of the venture. Each tile runs its personal useFrame callback — a operate that executes each body, managing a set of animation values that compose into the ultimate rendered state.
I attempted GSAP early on and deserted it. The issue is interruptibility. If a person clicks a shoe whereas a filter transition is mid-flight, each animation must easily redirect. Timeline-based methods struggle this — you spend extra time managing cancellation than writing animation logic. CSS animations had been by no means an possibility; they can not attain into WebGL uniforms.
I landed on easing.damp() from the fantastic maath — a frame-rate-independent exponential damping operate. Set a goal, and the worth chases it. Change the goal mid-animation, and the worth redirects. No cleanup, no cancellation.
const focusZ = useRef(0);
const curveZ = useRef(0);
const transitionZ = useRef(0);
const animatedPos = useRef({ x, y });
const filterOpacity = useRef(1);
const filterScale = useRef(1);
The ultimate place is a composite of those unbiased channels:
ref.present.place.set(
x,
y + transitionY.present,
curveZ.present + focusZ.present + transitionZ.present
);
Three Z contributions stack additively: curvature pushes distant tiles away, focus pops the chosen card ahead, transition offsets deal with enter/exit. Every damps at its personal velocity. They by no means battle as a result of they merely add.
Customized Shaders
I wrote two customized GLSL supplies utilizing drei’s shaderMaterial() helper, which provides you a declarative JSX interface (<holoCardMaterial />) backed by uncooked GLSL.
I selected per-material shaders over post-processing for a selected purpose: my results are interaction-driven and per-card. The holographic sheen solely seems on the chosen card. A post-processing bloom move would course of each pixel on display to have an effect on one card. Retaining the impact within the materials means zero overhead for the opposite 59.
Topography Background
The background is an animated contour-line subject — a dwelling topographic map that provides the scene a technical, CAD-like depth with out competing with the shoe imagery.
How the Isolines Work
The fragment shader samples 2D simplex noise (imported through glslify) and drifts it slowly over time:
#pragma glslify: snoise = require('glsl-noise/simplex/2nd')
float n = snoise(noiseUv * uScale + uTime * 0.05);
The contour traces come from a basic isoline extraction method. Multiply the noise by a frequency, take the fractional half to create repeating bands, then carve skinny traces on the band boundaries with a smoothstep pair:
float traces = fract(n * 5.0);
float sample = smoothstep(0.5 - uLineThickness, 0.5, traces)
- smoothstep(0.5, 0.5 + uLineThickness, traces);
The 2 smoothstep calls create a slim peak at 0.5 — precisely the place every band wraps round. uLineThickness (default 0.03) controls line width. The 5.0 multiplier controls what number of concentric rings seem per noise octave. I spent some time tuning these — too thick and it appears to be like like a loading spinner, too skinny and it disappears on low-DPI screens.
Masking and Grain
A round masks feathers the sides, and movie grain prevents banding:
float grain = (fract(sin(dot(vUv * 2.0, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) * 0.15;
vec3 finalColor = uColor + grain;
gl_FragColor = vec4(finalColor, sample * opacity * masks * uOpacity);
The entire thing sits on a aircraft at Z -15 with depthWrite={false} and renderOrder={-1} so it by no means occludes the playing cards. When the person zooms right into a shoe, uOpacity fades to 0.25 — the background recedes with out disappearing.
Holographic Card Materials
The cardboard materials provides a holographic sheen sweep when a card is chosen. This was probably the most enjoyable shader to write down as a result of the impact is fully pushed by a single uniform: uActive.
Vertex Respiratory
The vertex shader applies a delicate sine-wave scale oscillation on chosen playing cards:
float breath = sin(uTime * 2.0) * 0.015 * uActive;
float scale = 1.0 + breath;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos * scale, 1.0);
When uActive is 0, respiratory multiplies to zero — no work for unselected playing cards.
The Sheen Sweep
The fragment shader’s sheen impact was a cheerful accident. I initially needed a static holographic gradient, however mapping the sheen place on to uActive created this sweep animation totally free — because the uniform animates from 0 to 1, the band naturally slides throughout the cardboard:
float diagonal = (vUv.x * 0.8) + vUv.y;
float sheenPos = uActive * 2.5;
float sheenWidth = 0.5;
float dist = abs(diagonal - sheenPos);
float depth = 1.0 - smoothstep(0.0, sheenWidth, dist);
depth = pow(depth, 3.0);
The 0.8 multiplier on the X-axis is the “tilt” issue. In a typical $x + y$ setup, the gradient strikes at an ideal 45° angle. By weighting the X-axis barely lower than the Y, we rotate the sweep line to be extra vertical, which feels extra pure for a card being held as much as a lightweight supply.
The pow(depth, 3.0) is our “focus” management. With out it, the sheen is a large, muddy wash. By elevating the depth to an influence, we push the decrease values towards zero and hold solely the height, sharpening the falloff from a smooth glow right into a concentrated, high-end “specular” streak.
A fade-out on the finish prevents the sheen from sticking:
float sheenFade = 1.0 - smoothstep(0.7, 1.0, uActive);
vec3 sheenColor = vec3(0.85, 0.92, 1.0) * depth * 0.9 * sheenFade;
vec3 finalColor = baseColor + sheenColor * texColor.a;
The cool blue-white coloration is additive, masked by the feel’s alpha to remain throughout the shoe silhouette.
Uneven Timing
One small element that made a giant distinction: I animate uActive with completely different damping speeds for choice and deselection:
const activeDamp = isActive ? 0.6 : 0.15;
easing.damp(imageRef.present.materials, "uActive", isActive ? 1 : 0, activeDamp, delta);
Gradual in (0.6s), quick out (0.15s). You savor the reveal however by no means anticipate the dismiss. This asymmetry is adequately subtle that customers don’t consciously discover it, however eradicating it makes the entire interplay really feel sluggish.
The Digital camera Rig
I constructed a customized digicam rig from scratch as an alternative of utilizing drei’s OrbitControls. OrbitControls provides you a rotating digicam orbiting a middle level — I wanted a 2D panning digicam with bounded drag, rubber-band edges, and velocity-based tilt. Each constraint in OrbitControls would have fought me.
How It Works
The rig is a mutable singleton shared between the digicam element and each tile:
const rigState = {
goal: new THREE.Vector3(0, 2, 0),
present: new THREE.Vector3(0, 2, 0),
velocity: new THREE.Vector3(0, 0, 0),
zoom: CONFIG.zoomOut,
isDragging: false,
activeId: null,
};
Pointer occasions replace goal. Each body, present damps towards goal. The digicam reads present. This indirection is what makes every little thing really feel clean — person enter isn’t utilized immediately.
Drag and Bounds
I distinguish clicks from drags utilizing a distance threshold (5px desktop, 15px contact). Drag sensitivity scales with digicam distance so panning feels constant at any zoom stage.
Previous the grid edges, rubber-band resistance kicks in — you may overdrag 25% earlier than a tough clamp. On launch, the digicam snaps again. It’s the identical sample iOS makes use of for scroll bounce, and it communicates “you have got reached the sting” with out a onerous cease.
Choice
Clicking a tile triggers a simultaneous pan and zoom. The chosen card scales to 1.5x and pops ahead 2 models on Z. All different playing cards shrink to 0.5x and fade to fifteen% opacity — a dramatic highlight.

Filtering and Assortment Switching
The app helps two sorts of transitions, and the fascinating half is that they require basically completely different methods.
In-Place Filtering
Once you filter inside a set (say, “All” to “Jordan”), I don’t unmount and remount tiles. That may imply texture re-uploads, which suggests body drops. As an alternative, matching objects easily reposition to fill a denser grid whereas non-matching objects fade and shrink in place:
easing.damp(animatedPos.present, "x", basePos.x, 0.2, delta);
easing.damp(animatedPos.present, "y", basePos.y, 0.2, delta);
const targetFilterOpacity = matchesFilter ? 1 : 0;
const targetFilterScale = matchesFilter ? 1 : 0.5;
easing.damp(filterOpacity, "present", targetFilterOpacity, 0.06, delta);
Hidden tiles keep mounted however invisible — seen = false as soon as opacity drops beneath 0.01. This implies filter adjustments are instantaneous. No GPU work, simply uniform adjustments and a place recalculation.
Assortment Switching
Switching collections is a heavier operation — fully completely different shoe knowledge. I solved this with a layer stack: the outdated grid and new grid coexist briefly, every rendering as a separate element with a singular React key.
const handleCollectionSwitch = (index) => {
setGridLayers((prev) => {
const exitingLayers = prev.map((layer) =>
layer.mode === "enter"
? { ...layer, mode: "exit", startTime: now }
: layer
);
const newLayer = {
id: `grid-${index}-${now}`,
objects: collectionsData[index],
mode: "enter",
startTime: now,
};
return [...exitingLayers, newLayer];
});
setTimeout(() => {
setGridLayers((prev) => prev.filter((l) => l.mode === "enter"));
}, CONFIG.cleanupTimeout);
};
The outdated grid flies towards the digicam (Z +20) whereas the brand new one arrives from behind (Z -50). Every tile will get a random stagger delay. The impact reads as an explosion quite than a slide — deliberate. A easy crossfade felt flat. The Z-axis motion creates a way of bodily house, and the random stagger prevents the mechanical really feel of synchronized movement.
Getting into tiles additionally unfold on Y primarily based on their grid place — prime objects begin larger, backside objects decrease — making a “convergence from all instructions” really feel.
Polish
The Dynamic Island
The underside management bar borrows Apple’s Dynamic Island sample: a single glassmorphic container that morphs between states. I used Framer Movement’s format prop for this as a result of it handles one thing CSS can not — animating between utterly completely different DOM constructions.
MiniMap
A 2D <canvas> overlay runs its personal requestAnimationFrame loop, unbiased of R3F. Every shoe is a dot, the chosen shoe glows gold, and a white rectangle exhibits the seen viewport. On choice, the minimap easily zooms to 2.5x across the lively dot.
Efficiency
Three strategies saved us at 60fps:
Time-sliced mounting. Mounting 60 textured playing cards without delay causes a GPU spike. I mount 5 per body as an alternative, spreading the work throughout ~200ms. Quick sufficient to be invisible, gradual sufficient to stop jank. I couldn’t use InstancedMesh right here — every card has a singular texture, distinctive labels, and distinctive shader state. Instancing wants shared supplies.
Three-level culling. Each tile checks: has it totally exited? (skip all the useFrame callback.) Is it past the view distance? (conceal it.) Is its opacity close to zero? (seen = false.) These checks compound — a tile that has exited a set change skips all per-frame work, not simply rendering.
Mutable every little thing. Digital camera place, tile animation refs, shader uniforms — all mutated immediately in useFrame, by no means touching React state. The one re-renders occur on discrete person actions: choosing a tile, altering a filter, switching a set.
Conclusion
If I needed to compress the entire venture into one takeaway, it could be this: the onerous half shouldn’t be the 3D. The onerous half is making the 3D disappear. No one ought to take a look at this and suppose “oh, a WebGL demo.” They need to simply really feel like searching footwear is barely extra fascinating than it normally is.
The patterns that bought me there — exponential damping over tweens, per-material shaders over post-processing, mutable refs over React state for something that strikes — are usually not notably unique. They’re what falls out naturally whenever you cease treating React Three Fiber as a demo framework and begin treating it as a manufacturing one. More often than not I spent on this venture was not writing shaders. It was tuning damping constants, killing pointless re-renders, and ensuring a filter change mid-animation didn’t break one thing else.
If you’re constructing one thing comparable, steal the structure: React owns construction, GLSL owns pixels, and a skinny layer of mutable state bridges the 2 at 60fps. Every thing else is style.


