All components

3D Circular Image Gallery

GSAP animation component · Published 2026-07-21 · Updated 2026-08-16 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

32 full-colour film-still tiles (18 on phones) are placed around a circle with CSS 3D transforms to form a tilted ring seen in perspective. On load the tiles are dealt out from a single stacked point to their places around the ring with a staggered GSAP tween. GSAP ScrollTrigger (scrub) spins the ring on scroll and a pointer drag spins it directly with inertia; a mousemove listener parallax-tilts the gallery toward the cursor, and hovering a tile pushes it outward and swaps the large centred preview still, which is mounted with no border or outline of any kind.

How it's built

Categorygallery
Techgsap
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes

3d css-3d gallery scroll scrolltrigger rotation hover parallax circular

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

3D Circular Image Gallery

Goal

Build a full-screen 3D scene where 32 small image tiles (18 on phones) are arranged around a circle using CSS 3D transforms to form a single tilted ring seen in perspective — like a carousel viewed from above and to the side. Four GSAP-driven behaviours: (1) on load the tiles are dealt out — they start stacked on one point of the ring at 30% scale and fan to their places with a stagger; (2) scrolling spins the entire ring a full 360° via a scrubbed ScrollTrigger over a very tall page; (3) dragging with mouse or finger spins it directly, with inertia after release, composing with the scroll instead of fighting it; and (4) moving the mouse parallax-tilts the whole ring toward the cursor. Hovering (or tapping) any tile nudges it outward in 3D and swaps a large centered preview still. Nothing is a framework — plain DOM + CSS preserve-3d + perspective, with GSAP doing every animation.

The count is the design decision. The obvious version of this component packs 150 tiles at 2.4° apart, and it looks impressive in a thumbnail and illegible in use: neighbouring tiles overlap into a solid sawtooth wall, no single frame is readable, and you are looking at a texture, not at an archive. 32 tiles at 11.25° leave visible air between neighbours, so each still reads as a *frame*. The trap: whatever number you land on, the on-screen label has to be written from that same constant. The original shipped a hard-coded "150 frames" in the footer, which is exactly the kind of copy that survives three refactors of the number it describes.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin ScrollTrigger. No Lenis, no Three.js, no SplitText — the 3D is pure CSS transforms and GSAP tweens rotationX/rotationY/rotationZ/x/y/z. Register with gsap.registerPlugin(ScrollTrigger). Must run in a fresh Vite + npm project. Import shape:

import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);

Layout / HTML

Minimal markup — the tiles are generated by JS into an empty .gallery.

<body>
  <nav>
    <div class="brand"><a class="wordmark" href="#">Halation</a><span class="brand-tag">film stills archive</span></div>
    <div class="nav-links"><a href="#">Index</a><a href="#">Prints</a><a href="#">About</a></div>
  </nav>
  <footer>
    <!-- JS overwrites the number from the same constant that builds the ring -->
    <p class="mono frame-count">32 frames — drag or scroll to spin the reel</p>
    <p class="mono">© Halation 2026</p>
  </footer>

  <figure class="preview">
    <div class="preview-img"><img src="/c/3d-circular-img-gallery/img1.jpg" alt="" /></div>
    <figcaption class="frame-caption"><span class="frame-no">FR 01</span><span class="frame-text">Garden, blindfolded</span></figcaption>
  </figure>

  <div class="container">
    <div class="gallery"></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>

Key classes the JS/CSS depend on: .container (the perspective viewport and the drag surface), .gallery (the ring wrapper that JS fills, rotates and repositions), .item (each generated tile, JS-created), .preview (the whole projected-frame block, positioned by JS onto the ring's measured centre), .preview-img img (the large centered preview whose src swaps on hover), .frame-count (the footer label JS rewrites). nav / footer are fixed corner chrome, and JS reads their boxes to know where the ring is allowed to live.

Styling

Reset / global

  • * { margin:0; padding:0; box-sizing:border-box }
  • html, body { width:100%; height:1000vh; font-family:"Circular Std", sans-serif; background:#ffffff; } — the 1000vh body height is deliberate: it is the scroll track that drives the ring's full rotation. Fall back to any clean geometric sans (e.g. system sans) if the named font isn't available.

Corner chromenav, footer { position:fixed; width:100%; display:flex; justify-content:space-between; padding:2em; font-size:14px; font-weight:500; color:#000 }. nav { top:0 }, footer { bottom:0 }. Two <p> each, pushed to the left/right edges.

Perspective viewport.container { position:fixed; width:100%; height:100%; overflow:hidden; perspective:clamp(560px,145vw,1500px); touch-action:pan-y; cursor:grab; user-select:none; } plus .container.is-dragging { cursor:grabbing }. The perspective on this parent is what makes the ring read as 3D depth (near tiles large, far tiles small); scaling it with the viewport instead of pinning it at 1500px is what stops the ring collapsing into a flat strip on a phone. overflow:hidden clips the ring to the screen. touch-action:pan-y is not optional — see the drag section.

Ring wrapper.gallery { position:absolute; top:19%; left:49%; transform-style:preserve-3d; transform:translateX(-50%); }. Its X-tilt is applied by JS (40° on a 380px viewport ramping to 55° at ≥1200px) because the same angle does not work at every radius, and its top is overwritten by JS after measurement. preserve-3d is required so the child tiles keep their own 3D positions.

Tiles.item { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:45px; height:60px; background:#0d0d0d; margin:10px; transform-style:preserve-3d; }, dropping to 33×44px with margin:6px under max-width:768px. Every tile is a small 3:4 portrait rectangle, all stacked on the gallery's center point; their individual 3D rotations (set by JS, below) are what fan them out into the ring. The dark background is the placeholder before the image paints. preserve-3d on the tile too.

Imagesimg { width:100%; height:100%; object-fit:cover; filter:contrast(1.04); -webkit-user-drag:none; } (center-crop fill inside each tile). No grayscale(). The tempting move on a set of moody stills is filter:grayscale(1) so the page's two accent colours own all the chroma — it photographs beautifully and it throws the archive away, because the red of a safelight or a motel neon *is* the subject of these frames. Keep the ground cold (a bone #f0ede4) and let the frames be the only warm thing on the page; that contrast does the job grayscale was doing, without lying about the pictures. -webkit-user-drag:none (plus draggable=false in JS) stops Chrome starting a native image drag the moment you try to spin the ring.

Centered preview.preview { position:fixed; transform:translate(-50%,-50%); display:flex; flex-direction:column; align-items:center; gap:14px; pointer-events:none; } with its top/left written in px by JS. .preview-img { width:<set by JS>; aspect-ratio:3/2; overflow:hidden; background:#0d0d0d; box-shadow:0 22px 55px rgba(13,13,13,.2); }no border, no outline, no coloured glow. A 2px accent mount around the active still is the natural place to make an accent colour go solid, and it is also the thing that makes the still look like a selected item in a file picker rather than a projected frame; a soft downward cast shadow mounts it without drawing a line around it. pointer-events:none on the block matters as much: on mobile the preview sits *above* the ring (z-index:4, so near-side tiles don't slice across the still), and without it every drag that starts on the photo — i.e. most of them — dies on an element that has nothing to do.

The edge-code caption.frame-caption sits under the still in flow on desktop, where the ring's opening is wide enough that nothing crosses it. On mobile it must come out of flow (position:absolute; left:50%; transform:translateX(-50%), top written by JS from the ring's measured bottom edge) and carry an opaque bone plate (background:#f0ede4; padding:3px 7px). Black mono type left sitting on the reel loses whole letters into a dark red still — a "G" here, an "O" there — and the failure is invisible to every automated check you have: no console error, no layout warning, correct in the DOM, unreadable on screen. The measured position is the fix; the opaque plate is the safety net, because the bbox is sampled at one rotation and the tiles are discrete, so a spin can drop a fin a few pixels below the frame you measured.

GSAP effect (be exact — this is the whole component)

Wrap everything in window.onload = function () { … }. Grab const gallery = document.querySelector(".gallery") and const previewImage = document.querySelector(".preview-img img").

1. Generate the tiles
const TILE_TOTAL = window.innerWidth <= 768 ? 18 : 32;
for (let i = 0; i < TILE_TOTAL; i++) {
  const item = document.createElement("div");
  item.className = "item";
  const img = document.createElement("img");
  img.src = "/c/3d-circular-img-gallery/img" + ((i % 15) + 1) + ".jpg"; // cycle 15 sources
  img.draggable = false;
  item.appendChild(img);
  gallery.appendChild(item);
}
const angleIncrement = 360 / TILE_TOTAL;   // 11.25° desktop / 20° mobile between neighbours
document.querySelector(".frame-count").textContent =
  `${TILE_TOTAL} frames — drag or scroll to spin the reel`;

The tiles cycle through 15 source images (img1..img15, then repeat). Write the footer label from TILE_TOTAL, never as literal copy.

2. One state array, one write per frame (do this before anything else)

Everything below — the intro, the scroll spin and the drag — wants to own each tile's rotationZ. If each of them tweens that property directly they overwrite each other and the intro visibly snaps the moment you touch the wheel. Hold the tile's *data* instead and write the DOM once per frame from a single place:

const tiles = Array.from(gallery.querySelectorAll(".item")).map((el, index) => ({
  el,
  base: index * angleIncrement - 90, // this tile's own place on the circle
  spread: 0,                          // 0 = stacked on one point, 1 = fanned out (the intro tweens this)
}));

let radius = /* see step 3 */;
const applyRadius = () => tiles.forEach((t) =>
  gsap.set(t.el, { rotationY: 90, transformOrigin: `50% ${radius}px` }));

const writeTiles = (angle, withIntro) => tiles.forEach((t) => {
  if (withIntro) gsap.set(t.el, {
    rotationZ: t.base * t.spread + angle,
    scale: 0.3 + 0.7 * t.spread,
    autoAlpha: t.spread,
  });
  else gsap.set(t.el, { rotationZ: t.base * t.spread + angle });
});

rotationY: 90 turns each tile's plane so its face points along the ring; transformOrigin: "50% <radius>px" pushes the rotation pivot below the tile so the arm it swings on is the ring radius; rotationZ sweeps that arm around the circle. Because the parent .gallery carries the X-tilt and the container has a perspective, the result is a tilted ring seen from above and to the side.

Note what spread buys beyond the intro: t.base * t.spread means spread 0 puts *every* tile at the same angle, and because scale pivots on the ring's centre too, 30% scale also parks them near the middle. One number produces both halves of the dealing-out move.

3. Fit the ring by measuring it, not by predicting it

The projected size of a CSS-3D ring is a function of the radius, the tile size, the tilt, the container's perspective and its perspective-origin. Every closed-form estimate of that drifts on some viewport, and the failure mode is the one that made this component unusable on a phone: the ring ran off both edges and jammed into the header. Read the tiles' own rects instead — they are already the exact projected geometry:

function measureRing() {   // union of every tile's getBoundingClientRect()
  /* the union of every tile's rect → { cx, cy, top, bottom, width, height }, plus:
     inner = min over tiles of the distance from (cx,cy) to that tile's rect
           = the radius of the biggest circle that fits in the ring's hole      */
}

function fitRing() {
  applyRadius();
  writeLayoutPose();                    // every tile at t.base, scale 1, alpha 1 — see the trap below
  const navBottom = document.querySelector("nav").getBoundingClientRect().bottom;
  const footTop = document.querySelector("footer").getBoundingClientRect().top;
  const maxWidth = window.innerWidth * 0.94;
  const maxHeight = Math.max(180, (footTop - navBottom) * 0.96);

  for (let pass = 0; pass < 5; pass++) {          // tile size doesn't shrink with the radius,
    const box = measureRing();                     // so one division doesn't converge — iterate
    const k = Math.min(maxWidth / box.width, maxHeight / box.height, 1);
    if (k > 0.98) break;
    radius = Math.max(80, radius * k);
    applyRadius(); writeLayoutPose();
  }

  const wantCy = (navBottom + footTop) / 2;       // centre it in the band nav/footer leave free
  let topPx = gallery.offsetTop;                   // track it in JS: offsetTop rounds to integers
  for (let pass = 0; pass < 4; pass++) {           // ← this loop is not optional, see below
    const box = measureRing();
    const dy = wantCy - box.cy;
    if (Math.abs(dy) < 0.5) break;
    topPx += dy;
    gallery.style.top = `${topPx}px`;
  }

  const box = measureRing();
  preview.style.left = `${box.cx}px`;             // the preview goes on the ring's real centre
  preview.style.top = `${box.cy}px`;
  previewBox.style.width = isMobile()
    ? `${Math.max(120, Math.min(box.inner * 2 * 0.78, 200))}px`   // ← sized against the hole
    : `${radius * 0.75}px`;
  if (isMobile()) {                                // park the caption under the ring's real bottom
    const h = previewBox.getBoundingClientRect().height;
    caption.style.top = `${box.bottom + 22 - (box.cy - h / 2)}px`;
  } else caption.style.top = "";
}

Because the fit pass clamps, the starting radius can be *ambitious* on a phone — ask for min(44vw, 260), wider than the screen, and let the measurement claw it back to whatever actually fits. Desktop starts at min(400, max(200, 40vw)) and normally never shrinks. Call fitRing() once at load, again (debounced ~150ms) on resize, and once from document.fonts.ready; reset gallery.style.top = "" first each time so corrections aren't compounded.

**Trap 1 — measure the *finished* pose. If fitRing() reads the tiles while spread is 0 (before the intro) or mid-tween, it measures a stack of 32 tiles sitting on one point, concludes the ring is tiny, and hands you a radius three times too large. Write a throwaway layout pose (rotationZ: t.base, scale: 1, autoAlpha: 1) for the measurement, then collapse the tiles back in the same synchronous pass** before returning — no paint happens in the middle of your load handler, so the user never sees the finished ring flash before the intro plays it.

Trap 2 — the vertical correction does not converge in one pass. .gallery moves *inside* a container whose perspective-origin stays pinned to the container's own centre, so sliding the ring down also changes how it projects: the correction you just applied moves the thing you measured. A single top += wantCy - cy lands within a pixel on a wide desktop (where the ring is already near the centre) and roughly 35px low on a phone — and it looks like a deliberate composition, not a bug, so it survives review. Iterate until |dy| < 0.5, the same way you iterate the radius, and keep the running value in a JS variable because offsetTop rounds to integers.

Trap 3 — the ring's hole is much smaller than 2 × radius. The tiles carry rotationY: 90, so they stand edge-on to the ring like fins; tilted into perspective they project as long slivers reaching *inward*, nothing like their 33px CSS width. Sizing the centre still from the viewport (min(55vw, 240px)) or from the radius therefore produces, on a phone, a photo that covers the entire opening and leaves only the tips of the fins showing — the 3D ring, i.e. the whole component, disappears behind its own preview. Measure the hole (inner, above) and size the still to ~78% of that inscribed circle, with a floor so it stays legible. The reel stays the subject; the still stays readable.

4. The intro — deal the frames out
writeTiles(0, true);                    // collapse: everything on one point, 30% scale, alpha 0
gsap.to(tiles, {                        // tween the plain objects, not the elements
  spread: 1,
  duration: 0.85,
  ease: "power3.out",
  stagger: { each: 0.02, from: "start" },
  onComplete: () => { introRunning = false; /* stop paying for scale/alpha writes */ },
});

~1.5s end to end on desktop (0.85 + 32 × 0.02), ~1.2s on mobile. Under prefers-reduced-motion: reduce, skip the tween entirely: initialise spread: 1 and paint the ring already assembled — and see the reduced-motion section below, because skipping the intro alone is not honouring the preference.

5. Scroll and drag → one angle

The scroll spin stays a scrubbed ScrollTrigger over the 1000vh body, but it no longer touches the tiles; it only reports an angle:

ScrollTrigger.create({
  trigger: "body", start: "top top", end: "bottom bottom",
  scrub: 2,                                   // ~2s catch-up: the floaty, laggy spin
  onUpdate: (self) => { scrollAngle = self.progress * 360; },
});

The drag reports a second angle, and a gsap.ticker callback is the only thing that writes:

container.addEventListener("pointerdown", (e) => { dragging = true; velocity = 0; lastX = e.clientX; });
window.addEventListener("pointermove", (e) => {
  if (!dragging) return;
  const delta = -(e.clientX - lastX) * (110 / radius);   // degrees per pixel scales with the ring
  lastX = e.clientX;
  dragAngle += delta;
  velocity = velocity * 0.6 + delta * 0.4;               // smoothed, so a jittery last frame can't fling it
});
window.addEventListener("pointerup", () => { dragging = false; });   // velocity survives → inertia

gsap.ticker.add(() => {
  if (!dragging && velocity) { dragAngle += velocity; velocity *= 0.93; if (Math.abs(velocity) < 0.004) velocity = 0; }
  const target = scrollAngle + dragAngle;
  rendered += (target - rendered) * (dragging ? 0.5 : 0.12);
  writeTiles(rendered, introRunning);
});

Why it is built this way:

  • Scroll and drag compose by addition, so they cannot fight. Dragging while a scrub is still catching up just offsets it; neither input has to be disabled while the other is live.
  • The sign is negative on purpose. The near edge of the ring is the *bottom* of the oval, where an increasing rotationZ travels leftward on screen. Without the flip the reel runs away from the finger.
  • Degrees per pixel scales with radius (110 / radius). A fixed constant feels right on desktop and glacial on a phone, where the same finger travel crosses a much smaller ring.
  • Pointer events on window, not on the container, for move/up — otherwise releasing outside the viewport leaves the ring welded to the cursor. Add a blur listener to the same handler for the same reason.
  • One gsap.set per tile per frame, instead of the original's gsap.to per tile per scroll tick. At 150 tiles that was 150 short tweens created on every update; at 32 tiles with a single set it is nothing.

The trap that produces no error: without touch-action: pan-y on .container, the drag simply does not exist on a phone. The browser claims the touch for scrolling before a single pointermove fires — no exception, no warning, and it works perfectly under a desktop mouse, so it survives every test you are likely to run. pan-y gives vertical gestures back to the page (which spins the reel anyway, via the scrub) and keeps horizontal ones for the drag.

6. Mousemove → parallax tilt of the whole ring

A document-level mousemove listener tilts the entire gallery toward the cursor:

document.addEventListener("mousemove", function (event) {
  const centerX = window.innerWidth / 2;
  const centerY = window.innerHeight / 2;
  const percentX = (event.clientX - centerX) / centerX;   // −1 … +1
  const percentY = (event.clientY - centerY) / centerY;   // −1 … +1
  const rotateX = ringTiltDeg() + percentY * 2;            // ±2° around the resting tilt
  const rotateY = percentX * 2;                            // −2° … +2°
  gsap.to(gallery, {
    duration: 1,
    ease: "power2.out",
    rotateX: rotateX,
    rotateY: rotateY,
    overwrite: "auto",
  });
});

The amplitude is intentionally small (±2° on each axis around the resting tilt), duration:1, ease:"power2.out", overwrite:"auto" — a subtle, smoothed parallax lean of the whole ring, not a big move. This tween owns rotateX/rotateY on .gallery; the spin lives on the tiles' rotationZ, so the two never collide.

7. Hover a tile → push out + swap the centered preview

Inside the same per-tile loop, attach hover handlers:

item.addEventListener("mouseover", function () {
  const imgInsideItem = item.querySelector("img");
  previewImage.src = imgInsideItem.src;                 // large preview shows this tile's image
  gsap.to(item, { x: 10, z: 10, y: 10, ease: "power2.out", duration: 0.5 });
});
item.addEventListener("mouseout", function () {
  previewImage.src = "/c/3d-circular-img-gallery/img1.jpg";   // reset preview to the default image
  gsap.to(item, { x: 0, y: 0, z: 0, ease: "power2.out", duration: 0.5 });
});

On hover the tile is nudged +10px on all three local axes (x, y, z) — a small pop toward/out of the ring — with ease:"power2.out", duration:0.5, and the centered .preview-img swaps to that tile's picture. On mouse-out the tile eases back to x/y/z: 0 and the preview reverts to img1.jpg. Touch devices never fire mouseover, so bind the same swap to touchstart ({ passive: true }). These tweens own x/y/z, which the per-frame rotationZ write never touches — that separation is what lets a hover pop survive a spin.

Assets / images

15 source images (img1.jpgimg15.jpg), cycled across the tiles (tile *i* uses img((i % 15) + 1)), so each image repeats twice around a 32-tile ring. img1 doubles as the default centered preview. The set is one world, not an eclectic mix — an imagined film archive, in colour: a red-lit projection booth, rows of theatre seats, motel neon at night, a bedside lamp burning at 2 a.m., a 35mm projector head, a strip of negatives drying under a darkroom safelight, and two or three pale garden portraits for contrast. The reds recur across half the set and are what make the ring read as one reel rather than a stock-photo grid; that is precisely what a grayscale filter would have destroyed.

Source aspect ratios can be anything: tiles center-crop to 3:4 portrait (object-fit:cover) and the preview center-crops to 3:2 landscape. Because tiles render tiny, overall tone and colour matter far more than resolution. Do not use real brand imagery or client logos.

Responsive rebuild

The tile count is a breakpoint decision (innerWidth <= 768 ? 18 : 32) and the CSS tile size is another one (45×6033×44). Deciding the count once inside window.onload and never revisiting it means the two disagree the moment someone rotates a phone: 32 desktop tiles wearing the mobile tile size, a ring nobody designed, reached by the single most likely interaction on the device. Put the tile creation in a buildTiles() you can call again, remember which side of the breakpoint you built for, and in the debounced resize handler rebuild when that flips (kill any in-flight intro tween first, and don't replay the intro — the user is rotating a phone, not arriving).

Reduced motion

prefers-reduced-motion: reduce covers four things here, not one, and declaring support while only doing the first is worse than declaring none:

  1. The intro — skip it, paint the ring assembled.
  2. The cursor parallax — return early from the mousemove handler. It is pure decoration, the clearest case of movement the user did not ask for.
  3. The drag's inertia — the drag itself stays (it is a direct response to the user's own gesture), but the ring stops where the finger left it: zero the velocity on release and set the render lerp to 1 so there is no easing tail either.
  4. The scrub's trailing spinscrub: 2 means the ring keeps turning for ~2s after the wheel stops. Build the ScrollTrigger with scrub: true instead, so it tracks the scrollbar 1:1.

And read the query live: motionQuery.addEventListener("change", …), not a boolean sampled once at load. A user who switches the preference on mid-session keeps every animation they just turned off otherwise. On the change, zero the velocity, finish any running intro, settle the tilt back to its resting angle, and rebuild the ScrollTrigger with the new scrub.

Behavior notes

  • Pointer, touch and scroll. Interactions: scroll (scrubbed spin), pointer drag with inertia (direct spin, mouse and finger), mousemove (ring tilt), per-tile hover/tap (pop + preview swap). The intro plays once on load.
  • Everything is smoothed, nothing snaps. Scrub is 2 (laggy spin), the render lerps at 0.12 (0.5 while dragging so the ring stays under the finger), inertia decays at 0.93/frame, the mouse tilt tweens over duration:1, hover pops over 0.5.
  • Perf is light. 32 small DOM tiles, one gsap.set each per frame; no WebGL/canvas. The layout measurement is the only place that forces reflow, and it runs at load and on a debounced resize only.
  • Wrap in window.onload (not DOMContentLoaded) so the fixed chrome is laid out before the ring is built and measured against it.

Images

This component ships with 15 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/3d-circular-img-gallery/img1.jpg
https://motionprompts.dev/c/3d-circular-img-gallery/img10.jpg
https://motionprompts.dev/c/3d-circular-img-gallery/img11.jpg
https://motionprompts.dev/c/3d-circular-img-gallery/img12.jpg
https://motionprompts.dev/c/3d-circular-img-gallery/img13.jpg
https://motionprompts.dev/c/3d-circular-img-gallery/img14.jpg
… 9 more under https://motionprompts.dev/c/3d-circular-img-gallery/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • **Rules on *** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all of that at once, and it does it quietly — the ring renders, spins, tilts toward the cursor, and then a StrictMode remount leaves the gallery in a state the first load never produced. This component is more exposed to that than most, because it doesn't just animate existing markup — it *builds* its own DOM: the .item tiles are created and appended by the script itself, and nothing in the original ever removes one. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for the window load event before touching anything, and here that wait is load-bearing in a way it wasn't in the naive version: fitRing() measures the nav and footer boxes to decide where the ring may live, so those elements must be laid out with their real fonts before it runs. useEffect runs at commit, which is early enough that a web font swap can still change the header's height under you. Move the body into a useEffect with an empty dependency array, but re-run fitRing() once from document.fonts.ready (guard the promise with a cancelled flag so it can't fire after unmount).

*(2) Element lookups* — Give the component a root ref and resolve .preview-img / .preview-img img off it instead of off document. The one lookup that must not be rewritten this way is the ScrollTrigger.create trigger: it targets "body" on purpose, because the scrub is driven by the whole document's scroll track — the 1000vh page height this prompt sets up earlier — not by this component's own box. Pointing that trigger at the root ref "for consistency" silently changes the scrub distance to whatever height this component itself happens to occupy, which is not the full page. Leave that one lookup targeting document.body.

*(3) Cleanup — GSAP / ScrollTrigger* — Wrap the per-tile gsap.set calls, the intro tween, the pointer-driven tilt tween on the gallery, and the ScrollTrigger.create in a gsap.context scoped to the root ref, and revert it in the cleanup:

useEffect(() => {
  const ctx = gsap.context(() => {
    /* the per-tile gsap.set pass, the intro `spread` tween, the mousemove
       tilt tween on the gallery, and the ScrollTrigger.create */
  }, rootRef);
  return () => ctx.revert();
}, []);

That one call undoes the tiles' base poses, the intro, the tilt tween and the scroll trigger together. It does not undo four things this component also creates outside of GSAP's element bookkeeping, and each needs its own line in the same cleanup function:

  • The tiles themselves. gsap.context only tracks GSAP-owned state — tweens, triggers, the inline styles they wrote — it has no idea a <div> exists unless GSAP put it there. The .item elements come from a plain document.createElement/appendChild loop, so ctx.revert() leaves every one of them standing. A StrictMode remount then runs that loop a second time against a .gallery node the first pass never emptied, and the ring silently becomes 64 tiles deep instead of 32 — which, on this component, also poisons fitRing(), because the measurement now sees a doubled ring. Don't port the loop as-is: render the tiles declaratively, mapping over an array of indices in JSX, and give each one a callback ref that stores its node in a ref array (tileRefs.current[i] = el). Build the tiles state array from that ref array inside the effect. React then owns creating and destroying the tiles, so a remount is a no-op instead of a duplicate deck.
  • The gsap.ticker callback. This is the new one, and it is the easiest to miss because it is a GSAP API that gsap.context does not track: gsap.ticker.add(fn) is a global subscription, not a tween. ctx.revert() will not remove it. Keep the function reference and call gsap.ticker.remove(fn) in the cleanup, or a StrictMode remount leaves two render loops both writing rotationZ to the same tiles from two independent dragAngle/rendered closures — which looks exactly like a stuttering, half-speed ring and reads like a physics bug rather than a lifecycle one.
  • The pointer and document listeners. pointerdown on the container, pointermove/pointerup/pointercancel on window, blur on window, and mousemove on document are all bare addEventListener calls, invisible to ctx.revert(). Keep the references and remove all of them, or a remount leaves two drag handlers each adding their own delta to their own angle.
  • The window resize listener, its debounce timer, and the document.fonts.ready promise. Remove the listener, clearTimeout any pending timer, and guard the fonts promise with a cancelled flag — otherwise a re-fit that resolves in the gap between unmount and the callback still measures a nav that is gone and writes gsap.set calls onto tiles React has already thrown away.
  • The matchMedia change listener. motionQuery.addEventListener("change", …) lives on a MediaQueryList that outlives the component entirely — it is not on window, it is not a tween, and nothing sweeps it up. Remove it explicitly, or every mount leaves another handler that rebuilds a ScrollTrigger for a component that no longer exists.

The per-tile mouseover/mouseout/touchstart handlers don't need any of this manual bookkeeping once the tiles are declarative: express them as onMouseEnter/onMouseLeave/onTouchStart props on the JSX tile — which also gives you a natural place to read the tile's own image src for the preview swap — and React removes them for free the moment a tile unmounts, StrictMode remount included.