bottega

effects

Cube Grid Gallery

A wall of 3D cubes that doubles as a six-sided image gallery. Each face image is sliced across the entire grid, so all cubes together reconstruct one photo per face — front, right, back, left, top, and bottom. Selecting a face in the accessible radiogroup flips every cube in unison to bring that picture forward, with a per-cube stagger that ripples across the wall and a Z-recoil wave at the midpoint of the flip. An optional spin-in intro reveals the first face on mount. The grid auto-arranges into a near-square layout from the cube count, computed in JS for portability; the staggered rotation, Z-wave, and intro all live as co-located CSS keyframes driven by custom properties (no per-frame React).

Ported from NiklasKnaack (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/cube-grid-gallery

Usage

Usagetsx
<CubeGridGallery faces={[]} />

Props

PropTypeDefaultDescription
faces*Partial<Record<"front" | "right" | "back" | "left" | "top" | "bottom", string>> | string[]One image URL per cube face, in {@link CUBE_FACES} order (front, right, back, left, top, bottom). Provide up to 6; any face left undefined renders an empty (token-tinted) surface. Each image is tiled across the entire cube grid, so all cubes together form one picture.
countnumber64Total number of cubes in the wall (default 64). Arranged into a near-square grid.
cubeSizenumber75Edge length of each cube in px (default 75).
gapnumber0Gap between cubes in px (default 0 — a seamless wall).
staggerMsnumber15Per-cube ripple delay in ms; later cubes start their flip later (default 15).
durationMsnumber1000Full rotation duration for a single cube in ms (default 1000).
introbooleantruePlay the spin-in intro on mount (default true). Ignored under reduced motion.
faceenumControlled active face. Omit for uncontrolled use.
defaultFaceenumfrontInitial face when uncontrolled (default "front").
onFaceChange(face: "front" | "right" | "back" | "left" | "top" | "bottom") => voidFires whenever the active face changes (controlled or not).
navLabelstringCube faceAccessible label for the navigation radiogroup.
faceLabelsPartial<Record<"front" | "right" | "back" | "left" | "top" | "bottom", string>>Override the per-face button labels.
classNamestringMerged onto the root element.

Source

cube-grid-gallery.tsxtsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
} from "react";
import type React from "react";
import { useReducedMotion } from "motion/react";
import styles from "./cube-grid-gallery.module.css";

/**
 * The six faces of a cube, in the order the original navigation exposes them.
 * Each face is a single image that gets sliced across the whole grid of cubes,
 * so the grid reconstructs one photo per face (a wall of cubes = one picture).
 */
export const CUBE_FACES = [
  "front",
  "right",
  "back",
  "left",
  "top",
  "bottom",
] as const;

export type CubeFace = (typeof CUBE_FACES)[number];

/** rotateX / rotateY (degrees) that bring each face to the front of the viewport. */
const FACE_ROTATION: Record<CubeFace, { rx: number; ry: number }> = {
  front: { rx: 0, ry: 0 },
  right: { rx: 0, ry: -90 },
  back: { rx: 0, ry: 180 },
  left: { rx: 0, ry: 90 },
  top: { rx: 90, ry: 0 },
  bottom: { rx: -90, ry: 0 },
};

const DEFAULT_LABELS: Record<CubeFace, string> = {
  front: "Front face",
  right: "Right face",
  back: "Back face",
  left: "Left face",
  top: "Top face",
  bottom: "Bottom face",
};

export type CubeGridGalleryProps = {
  /**
   * One image URL per cube face, in {@link CUBE_FACES} order
   * (front, right, back, left, top, bottom). Provide up to 6; any face left
   * undefined renders an empty (token-tinted) surface. Each image is tiled
   * across the entire cube grid, so all cubes together form one picture.
   */
  faces: Partial<Record<CubeFace, string>> | (string | undefined)[];
  /** Total number of cubes in the wall (default 64). Arranged into a near-square grid. */
  count?: number;
  /** Edge length of each cube in px (default 75). */
  cubeSize?: number;
  /** Gap between cubes in px (default 0 — a seamless wall). */
  gap?: number;
  /** Per-cube ripple delay in ms; later cubes start their flip later (default 15). */
  staggerMs?: number;
  /** Full rotation duration for a single cube in ms (default 1000). */
  durationMs?: number;
  /** Play the spin-in intro on mount (default true). Ignored under reduced motion. */
  intro?: boolean;
  /** Controlled active face. Omit for uncontrolled use. */
  face?: CubeFace;
  /** Initial face when uncontrolled (default "front"). */
  defaultFace?: CubeFace;
  /** Fires whenever the active face changes (controlled or not). */
  onFaceChange?: (face: CubeFace) => void;
  /** Accessible label for the navigation radiogroup. */
  navLabel?: string;
  /** Override the per-face button labels. */
  faceLabels?: Partial<Record<CubeFace, string>>;
  /** Merged onto the root element. */
  className?: string;
} & Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">;

function normalizeFaces(
  faces: CubeGridGalleryProps["faces"],
): Partial<Record<CubeFace, string>> {
  if (Array.isArray(faces)) {
    const out: Partial<Record<CubeFace, string>> = {};
    CUBE_FACES.forEach((name, idx) => {
      const url = faces[idx];
      if (url) out[name] = url;
    });
    return out;
  }
  return faces;
}

export function CubeGridGallery({
  faces,
  count = 64,
  cubeSize = 75,
  gap = 0,
  staggerMs = 15,
  durationMs = 1000,
  intro = true,
  face: controlledFace,
  defaultFace = "front",
  onFaceChange,
  navLabel = "Cube face",
  faceLabels,
  className = "",
  style: consumerStyle,
  ...props
}: CubeGridGalleryProps) {
  const groupName = useId();

  // Mount-gate + null-safe reduced-motion check (contract).
  // useReducedMotion() is null on SSR but a concrete boolean on first client
  // render, so we render a byte-identical static base on the server/first client
  // pass and only enable motion (intro, staggered flips) after mount.
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
  }, []);
  const prefersReducedMotion = useReducedMotion();
  const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
  const animated = mounted && !reduce;

  // Uncontrolled state falls back to controlled prop when provided.
  const [internalFace, setInternalFace] = useState<CubeFace>(defaultFace);
  const activeFace = controlledFace ?? internalFace;

  const selectFace = useCallback(
    (next: CubeFace) => {
      if (controlledFace === undefined) setInternalFace(next);
      onFaceChange?.(next);
    },
    [controlledFace, onFaceChange],
  );

  // Grid is computed in JS (robust everywhere) rather than via the original's
  // sibling-index()/sqrt()/round() CSS, which needs very new browser support.
  const { cols, rows } = useMemo(() => {
    const c = Math.max(1, Math.ceil(Math.sqrt(count)));
    return { cols: c, rows: Math.max(1, Math.ceil(count / c)) };
  }, [count]);

  const resolvedFaces = useMemo(() => normalizeFaces(faces), [faces]);
  const labels = { ...DEFAULT_LABELS, ...faceLabels };

  const { rx, ry } = FACE_ROTATION[activeFace];
  const step = cubeSize + gap;
  // How far cubes recoil in Z during a flip — scaled to cube size, like the original.
  const zPush = cubeSize * 3.75;

  const rootStyle = {
    ...consumerStyle,
    "--cube-size": `${cubeSize}px`,
    "--cube-gap": `${gap}px`,
    "--cube-step": `${step}px`,
    "--cube-cols": String(cols),
    "--cube-rows": String(rows),
    "--cube-rx": `${rx}deg`,
    "--cube-ry": `${ry}deg`,
    "--cube-z-push": `${zPush}px`,
    "--cube-duration": `${durationMs}ms`,
    "--cube-stagger": `${staggerMs}ms`,
  } as React.CSSProperties & Record<string, string>;

  const rootClassName = [styles.root, animated && intro && styles.intro, className]
    .filter(Boolean)
    .join(" ");

  // Each cube reconstructs one slice (col x / row y) of every face image.
  const cubes = useMemo(
    () =>
      Array.from({ length: count }, (_, i) => ({
        i,
        x: i % cols,
        y: Math.floor(i / cols),
      })),
    [count, cols],
  );

  // Live region announces face changes for screen readers without re-rendering motion.
  const liveRef = useRef<HTMLSpanElement>(null);

  return (
    <div
      {...props}
      className={rootClassName}
      style={rootStyle}
      data-animated={animated ? "true" : "false"}
    >
      <span ref={liveRef} role="status" aria-live="polite" className={styles.srOnly}>
        {labels[activeFace]}
      </span>

      <div className={styles.viewport} aria-hidden="true">
        {cubes.map(({ i, x, y }) => (
          <div
            key={i}
            className={styles.cube}
            style={
              {
                "--i": String(i),
                "--x": String(x),
                "--y": String(y),
              } as React.CSSProperties & Record<string, string>
            }
          >
            {CUBE_FACES.map((faceName) => {
              const src = resolvedFaces[faceName];
              return (
                <i
                  key={faceName}
                  data-face={faceName}
                  className={styles.face}
                  style={
                    src
                      ? ({ backgroundImage: `url("${src}")` } as React.CSSProperties)
                      : undefined
                  }
                />
              );
            })}
          </div>
        ))}
      </div>

      <div className={styles.nav} role="radiogroup" aria-label={navLabel}>
        {CUBE_FACES.map((faceName) => {
          const checked = faceName === activeFace;
          return (
            <span key={faceName} className={styles.button}>
              <input
                type="radio"
                name={groupName}
                aria-label={labels[faceName]}
                checked={checked}
                onChange={() => selectFace(faceName)}
                className={styles.input}
              />
            </span>
          );
        })}
        <i
          aria-hidden="true"
          className={styles.movingDot}
          style={
            {
              "--nav-index": String(CUBE_FACES.indexOf(activeFace)),
            } as React.CSSProperties & Record<string, string>
          }
        />
      </div>
    </div>
  );
}

Dependencies

  • motion
  • @bottega/tokens