backgrounds
Parallax Image Wall
A full-bleed, infinitely scrolling image wall built from five depth layers — nearer layers are larger, faster and more opaque while far layers shrink, slow and fade, producing a continuous parallax depth cue. Tiles drift horizontally and wrap seamlessly at the edges, each with an out-of-phase sine float and micro-scale pulse so the field breathes. A single requestAnimationFrame loop writes translate3d/scale transforms straight to imperatively-rendered <img> nodes (no per-frame React reconciliation). Drag (pointer) imparts decaying momentum and the wheel accelerates or reverses the scroll direction, mirroring the original Three.js gallery — re-authored as a dependency-light DOM/CSS port (no WebGL).
Ported from ol-ivier (MIT)
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/parallax-image-wallUsage
<ParallaxImageWall images={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images* | string[] | — | Image URLs that get shuffled across all depth layers. |
| className | string | Additional class names merged onto the root element. | |
| speed | number | 1 | Base scroll speed multiplier applied on top of per-layer speeds (default 1). |
| drift | enum | 1 | Auto-drift direction at rest: 1 = leftward, -1 = rightward, 0 = still until interaction (default 1). |
| staticHover | boolean | false | Disable drag/wheel interaction, leaving only ambient drift (default false). |
| label | string | Parallax image wall | Accessible label for the decorative region (default "Parallax image wall"). |
Source
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./parallax-image-wall.module.css";
/**
* Per-depth-layer config: nearer layers are larger, faster and more opaque,
* far layers shrink, slow down and fade — the parallax depth cue. Ported from
* the original five-layer Three.js gallery (scale / speed / opacity tuples).
*/
const LAYER_CONFIG = [
{ scale: 1.5, speed: 80, opacity: 1.0 },
{ scale: 1.0, speed: 40, opacity: 0.85 },
{ scale: 0.8, speed: 30, opacity: 0.7 },
{ scale: 0.6, speed: 20, opacity: 0.55 },
{ scale: 0.5, speed: 15, opacity: 0.4 },
] as const;
const DEPTH_LAYERS = LAYER_CONFIG.length;
const BASE_TILE = 160; // px — the original MAX_WIDTH/HEIGHT at scale 1.
type Tile = {
id: number;
src: string;
x: number; // left edge, px (world space, before layer offset)
baseY: number; // resting vertical center, px
w: number;
h: number;
seed: number; // phase offset for the float/pulse so tiles don't beat in sync
};
type LayerState = {
cfg: (typeof LAYER_CONFIG)[number];
tiles: Tile[];
el: HTMLDivElement | null;
};
export type ParallaxImageWallProps = {
/** Image URLs that get shuffled across all depth layers. */
images: string[];
/** Additional class names merged onto the root element. */
className?: string;
/** Base scroll speed multiplier applied on top of per-layer speeds (default 1). */
speed?: number;
/** Auto-drift direction at rest: 1 = leftward, -1 = rightward, 0 = still until interaction (default 1). */
drift?: 0 | 1 | -1;
/** Disable drag/wheel interaction, leaving only ambient drift (default false). */
staticHover?: boolean;
/** Accessible label for the decorative region (default "Parallax image wall"). */
label?: string;
} & Omit<React.HTMLAttributes<HTMLDivElement>, "children">;
let TILE_UID = 0;
function rand(min: number, max: number) {
return Math.random() * (max - min) + min;
}
function shuffle<T>(arr: readonly T[]): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export function ParallaxImageWall({
images,
className = "",
speed = 1,
drift = 1,
staticHover = false,
label = "Parallax image wall",
...props
}: ParallaxImageWallProps) {
const rootRef = useRef<HTMLDivElement>(null);
const layersRef = useRef<LayerState[]>([]);
const frameRef = useRef<number | null>(null);
// Interaction state lives in refs so the rAF loop reads it without re-rendering.
const speedFactorRef = useRef<number>(drift); // signed scroll multiplier
const velocityRef = useRef<number>(0); // momentum from drag, decays each frame
const draggingRef = useRef<boolean>(false);
const lastXRef = useRef<number>(0);
const lastTimeRef = useRef<number>(0);
// Mount-gate + null-safe reduced-motion check (contract pattern).
// useReducedMotion() is null on SSR but a concrete boolean on first client
// render; treat null as reduced. Server/first-client render is a byte-identical
// static snapshot, and the rAF/drag/wheel rig only attaches after mount.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const pool = useMemo(
() => (images.length > 0 ? images : []),
[images],
);
// Build the initial tile set for every layer, filling the viewport width.
const seedLayers = useCallback(
(viewportW: number, viewportH: number) => {
const shuffled = shuffle(pool);
let cursor = 0;
const next = () => {
if (pool.length === 0) return "";
const src = shuffled[cursor % shuffled.length];
cursor++;
return src;
};
const layers: LayerState[] = LAYER_CONFIG.map((cfg) => {
const tiles: Tile[] = [];
// Start one tile-width off the left edge so wrap-in is seamless.
let x = -viewportW * 0.2;
while (x < viewportW + BASE_TILE * cfg.scale) {
const sizeVar = rand(0.85, 1.15);
const w = BASE_TILE * cfg.scale * sizeVar;
const h = BASE_TILE * cfg.scale * sizeVar;
const spacing = w * rand(0.5, 0.9);
tiles.push({
id: TILE_UID++,
src: next(),
x,
baseY: rand(h / 2, Math.max(h / 2, viewportH - h / 2)),
w,
h,
seed: rand(0, 1000),
});
x += w + spacing;
}
return { cfg, tiles, el: null };
});
layersRef.current = layers;
},
[pool],
);
// The single rAF tick: advance every tile, wrap edges, apply float + pulse,
// and write transforms straight to the DOM (no per-frame React state).
const tick = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const now = performance.now();
const dt = Math.min(40, now - lastTimeRef.current) / 1000;
lastTimeRef.current = now;
const viewportW = root.clientWidth;
// Momentum decay; while a drag flings, momentum dictates direction.
velocityRef.current *= 0.92;
if (Math.abs(velocityRef.current) > 0.001) {
speedFactorRef.current = Math.sign(velocityRef.current);
}
const dir = speedFactorRef.current;
for (const layer of layersRef.current) {
if (!layer.el) continue;
const layerSpeed = layer.cfg.speed * speed;
for (let i = 0; i < layer.tiles.length; i++) {
const t = layer.tiles[i];
t.x += layerSpeed * dir * dt + velocityRef.current * layer.cfg.speed * dt;
// Infinite wrap: recycle past the trailing edge to the opposite side.
if (dir >= 0 && t.x - t.w / 2 > viewportW) {
t.x = -t.w / 2 - rand(0, t.w);
} else if (dir < 0 && t.x + t.w / 2 < 0) {
t.x = viewportW + t.w / 2 + rand(0, t.w);
}
const pulse = 1 + Math.sin(now * 0.001 + t.seed) * 0.015;
const floatY = Math.sin(now * 0.001 + t.seed) * 5;
const node = layer.el.children[i] as HTMLElement | undefined;
if (node) {
node.style.transform = `translate3d(${t.x.toFixed(2)}px, ${(
t.baseY +
floatY
).toFixed(2)}px, 0) translate(-50%, -50%) scale(${pulse.toFixed(4)})`;
}
}
}
frameRef.current = requestAnimationFrame(tick);
}, [speed]);
// Mount the animated rig: seed layers, paint tiles into the DOM, start rAF.
// Re-runs only when motion is (re)enabled or the image pool / config changes.
useEffect(() => {
if (!animate) return;
const root = rootRef.current;
if (!root || pool.length === 0) return;
let alive = true;
const buildAndPaint = () => {
const viewportW = root.clientWidth || 1;
const viewportH = root.clientHeight || 1;
seedLayers(viewportW, viewportH);
// Render tiles imperatively (decorative, non-React surface) so the rAF
// loop owns the DOM without React reconciling 50+ nodes per frame.
for (const layer of layersRef.current) {
const layerEl = root.querySelector<HTMLDivElement>(
`[data-layer="${LAYER_CONFIG.indexOf(layer.cfg)}"]`,
);
layer.el = layerEl;
if (!layerEl) continue;
layerEl.replaceChildren();
for (const t of layer.tiles) {
const img = document.createElement("img");
img.src = t.src;
img.alt = "";
img.decoding = "async";
img.loading = "eager";
img.draggable = false;
img.className = styles.tile;
img.style.width = `${t.w}px`;
img.style.height = `${t.h}px`;
img.style.opacity = String(layer.cfg.opacity);
img.style.transform = `translate3d(${t.x}px, ${t.baseY}px, 0) translate(-50%, -50%)`;
layerEl.appendChild(img);
}
}
lastTimeRef.current = performance.now();
if (alive) frameRef.current = requestAnimationFrame(tick);
};
buildAndPaint();
// Rebuild on resize so the wall always covers the viewport.
const ro = new ResizeObserver(() => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
buildAndPaint();
});
ro.observe(root);
return () => {
alive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
ro.disconnect();
};
}, [animate, pool, seedLayers, tick]);
// Drag + wheel interaction. Mirrors the original: drag sets a decaying
// velocity; wheel accelerates and can flip scroll direction.
useEffect(() => {
if (!animate || staticHover) return;
const root = rootRef.current;
if (!root) return;
const getX = (e: PointerEvent) => e.clientX;
const onDown = (e: PointerEvent) => {
draggingRef.current = true;
lastXRef.current = getX(e);
root.setPointerCapture?.(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
const x = getX(e);
velocityRef.current = (x - lastXRef.current) * 0.02;
lastXRef.current = x;
};
const onUp = () => {
draggingRef.current = false;
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const direction = Math.sign(e.deltaY) >= 0 ? 1 : -1;
const accel = 0.8;
const maxSpeed = 5;
const sf = direction * (Math.abs(speedFactorRef.current) + accel);
speedFactorRef.current =
Math.sign(sf) * Math.min(maxSpeed, Math.abs(sf));
velocityRef.current = 0;
};
root.addEventListener("pointerdown", onDown);
root.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
root.addEventListener("wheel", onWheel, { passive: false });
return () => {
root.removeEventListener("pointerdown", onDown);
root.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
root.removeEventListener("wheel", onWheel);
};
}, [animate, staticHover]);
// Static fallback (SSR, first client render, reduced motion): a calm, evenly
// tiled snapshot per layer so the depth reads without any motion or input.
const staticLayers = useMemo(() => {
if (animate || pool.length === 0) return null;
return LAYER_CONFIG.map((cfg, l) => {
const count = 6;
const size = BASE_TILE * cfg.scale;
const items = Array.from({ length: count }, (_, i) => {
const src = pool[(l * count + i) % pool.length];
return (
<img
key={i}
src={src}
alt=""
aria-hidden="true"
draggable={false}
className={styles.tile}
style={{
width: size,
height: size,
opacity: cfg.opacity,
left: `${(i / count) * 100 + 4}%`,
top: `${15 + ((l * 13 + i * 17) % 60)}%`,
}}
/>
);
});
return (
<div key={l} className={styles.layer} data-static="true">
{items}
</div>
);
});
}, [animate, pool]);
return (
<div
ref={rootRef}
className={[styles.root, className].filter(Boolean).join(" ")}
role="img"
aria-label={label}
data-interactive={animate && !staticHover ? "true" : "false"}
tabIndex={animate && !staticHover ? 0 : undefined}
{...props}
>
{animate
? LAYER_CONFIG.map((_, l) => (
<div
key={l}
className={styles.layer}
data-layer={l}
aria-hidden="true"
/>
))
: staticLayers}
<span className={styles.vignette} aria-hidden="true" />
</div>
);
}
Dependencies
- motion
- @bottega/tokens



