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):
// 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/cube-grid-galleryUsage
<CubeGridGallery faces={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| count | number | 64 | Total number of cubes in the wall (default 64). Arranged into a near-square grid. |
| cubeSize | number | 75 | Edge length of each cube in px (default 75). |
| gap | number | 0 | Gap between cubes in px (default 0 — a seamless wall). |
| staggerMs | number | 15 | Per-cube ripple delay in ms; later cubes start their flip later (default 15). |
| durationMs | number | 1000 | Full rotation duration for a single cube in ms (default 1000). |
| intro | boolean | true | Play the spin-in intro on mount (default true). Ignored under reduced motion. |
| face | enum | — | Controlled active face. Omit for uncontrolled use. |
| defaultFace | enum | front | Initial face when uncontrolled (default "front"). |
| onFaceChange | (face: "front" | "right" | "back" | "left" | "top" | "bottom") => void | — | Fires whenever the active face changes (controlled or not). |
| navLabel | string | Cube face | Accessible label for the navigation radiogroup. |
| faceLabels | Partial<Record<"front" | "right" | "back" | "left" | "top" | "bottom", string>> | — | Override the per-face button labels. |
| className | string | Merged onto the root element. |
Source
"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