Dirck Mulder
Components||4 min read

Build a Dome Gallery in React

Arrange images across a curved 3D dome surface for a unique spatial gallery experience.

Grids are predictable. Domes are not. When your gallery wraps images across a curved 3D surface, it tells visitors they are somewhere worth exploring, not just somewhere worth scrolling.

The final result

What we are building

A full CSS 3D sphere of images that you can drag to spin. Clicking an image expands it with a smooth zoom animation. The sphere rotates with inertia on release, decelerating gradually based on how fast you dragged. Everything is CSS transforms with no WebGL.

Setting up

tsx
import { useEffect, useMemo, useRef, useCallback } from 'react';

No external animation library. The rotation and inertia are implemented with requestAnimationFrame and manual math.

Building the component

Building the item grid

Items are placed on a cylindrical grid with alternating row offsets. The buildItems function creates a list of positions in degrees:

tsx
function buildItems(pool: ImageItem[], seg: number): ItemDef[] {
  const xCols = Array.from({ length: seg }, (_, i) => -37 + i * 2);
  const evenYs = [-4, -2, 0, 2, 4];
  const oddYs = [-3, -1, 1, 3, 5];
  const coords = xCols.flatMap((x, c) => {
    const ys = c % 2 === 0 ? evenYs : oddYs;
    return ys.map(y => ({ x, y, sizeX: 2, sizeY: 2 }));
  });
  // ... image assignment with shuffle to avoid consecutive duplicates
}

Positioning items with CSS custom properties

Each sphere item gets its rotation as CSS variables. The CSS then computes the actual rotateY and rotateX from those values combined with the global rotation:

css
.sphere-item {
  transform:
    rotateY(calc(var(--rot-y) * (var(--offset-x) + (var(--item-size-x) - 1) / 2) + var(--rot-y-delta, 0deg)))
    rotateX(calc(var(--rot-x) * (var(--offset-y) - (var(--item-size-y) - 1) / 2) + var(--rot-x-delta, 0deg)))
    translateZ(var(--radius));
}

Updating the sphere rotation is a single style.transform write on the sphere container:

tsx
const applyTransform = (xDeg: number, yDeg: number) => {
  const el = sphereRef.current;
  if (el)
    el.style.transform = `translateZ(calc(var(--radius) * -1)) rotateX(${xDeg}deg) rotateY(${yDeg}deg)`;
};

Inertia after drag

Velocity is captured during the drag. On release, an animation loop applies friction until the velocity drops below a threshold:

tsx
const startInertia = useCallback((vx: number, vy: number) => {
  let vX = clamp(vx, -MAX_V, MAX_V) * 80;
  let vY = clamp(vy, -MAX_V, MAX_V) * 80;
  const frictionMul = 0.94 + 0.055 * d;
  const step = () => {
    vX *= frictionMul;
    vY *= frictionMul;
    if (Math.abs(vX) < stopThreshold && Math.abs(vY) < stopThreshold) return;
    const nextX = clamp(rotationRef.current.x - vY / 200, -maxVerticalRotationDeg, maxVerticalRotationDeg);
    const nextY = wrapAngleSigned(rotationRef.current.y + vX / 200);
    rotationRef.current = { x: nextX, y: nextY };
    applyTransform(nextX, nextY);
    inertiaRAF.current = requestAnimationFrame(step);
  };
  inertiaRAF.current = requestAnimationFrame(step);
}, [dragDampening, maxVerticalRotationDeg, stopInertia]);

Image expand animation

Clicking a tile captures its screen position, creates an overlay element starting from that position, and animates it to fill the frame:

tsx
const tx0 = tileR.left - frameR.left;
const ty0 = tileR.top - frameR.top;
const sx0 = tileR.width / frameR.width;
const sy0 = tileR.height / frameR.height;
overlay.style.transform = `translate(${tx0}px, ${ty0}px) scale(${sx0}, ${sy0})`;

setTimeout(() => {
  overlay.style.opacity = '1';
  overlay.style.transform = 'translate(0px, 0px) scale(1, 1)';
}, 16);

How to use it

tsx
<DomeGallery
  images={[
    { src: '/photo1.jpg', alt: 'Mountain' },
    { src: '/photo2.jpg', alt: 'Ocean' },
  ]}
  segments={35}
  fit={0.5}
  dragSensitivity={20}
  grayscale={true}
  overlayBlurColor="#060010"
/>

Key takeaways

  • The sphere radius is computed from the container size via a ResizeObserver, so the dome always fills its container regardless of screen size.
  • Vertical rotation is clamped to a small range (maxVerticalRotationDeg) to prevent the sphere from flipping upside down.
  • The dg-scroll-lock class added to document.body during drag prevents the page from scrolling while the user is interacting with the dome.