bottega

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):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/parallax-image-wall

Usage

Usagetsx
<ParallaxImageWall images={[]} />

Props

PropTypeDefaultDescription
images*string[]Image URLs that get shuffled across all depth layers.
classNamestringAdditional class names merged onto the root element.
speednumber1Base scroll speed multiplier applied on top of per-layer speeds (default 1).
driftenum1Auto-drift direction at rest: 1 = leftward, -1 = rightward, 0 = still until interaction (default 1).
staticHoverbooleanfalseDisable drag/wheel interaction, leaving only ambient drift (default false).
labelstringParallax image wallAccessible label for the decorative region (default "Parallax image wall").

Source

parallax-image-wall.tsxtsx
"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