All components

Circular Image Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

On page load a GSAP timeline lays out 15 image tiles into a full circle, each scaling from 0 to 1 and rotating tangentially with a staggered power2.out entrance. Clicking any tile shrinks the others away (power2.in stagger) and animates the chosen tile to the center, scaling it up 5x; clicking again reverses it back into the ring.

How it's built

Categorygallery
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

gallery circular gsap timeline click radial stagger editorial minimal

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

Circular Image Gallery — Radial Ring with Click-to-Focus

Goal

Build a full-viewport, minimal editorial page where, on load, 15 small portrait image tiles fly out from the exact center of the screen and arrange themselves into a perfect circle — each tile scaling up from nothing, sliding to its spot on the ring, and rotating so it sits like a petal/clock-numeral along its radial spoke, all with a staggered decelerating entrance. Clicking any tile makes every other tile shrink away to nothing and the chosen tile glides back to the center and scales up 5× to become a hero image; clicking it again sends it back to its ring position and pops all the others back in. The star effect is this GSAP-timeline radial fan-out plus the click-to-focus zoom / restore.

Tech

Vanilla HTML/CSS/JS with ES-module imports. Use gsap (npm) only — no plugins, no ScrollTrigger, no smooth-scroll, no other libraries. Ship one index.html, one styles.css, one ES-module script.js (<script type="module" src="./script.js">). It must run in a fresh Vite + npm project. import gsap from "gsap"; is the single import.

Layout / HTML

Dead-simple, static skeleton — the JS injects the <img> into each tile at runtime, so the markup ships 15 empty .item divs:

<body>
  <nav>
    <p>Motionprompts &nbsp;&nbsp;&nbsp;&nbsp; / &nbsp;&nbsp;&nbsp;&nbsp;26092017</p>
    <p>Youtube</p>
  </nav>
  <footer>
    <p>Subscribe</p>
    <p>Motionprompts</p>
  </footer>
  <div class="container">
    <div class="gallery">
      <div class="item"></div>
      <!-- exactly 15 identical .item divs -->
    </div>
  </div>
  <script type="module" src="./script.js"></script>
</body>
  • nav / footer — fixed UI chrome pinned to the top and bottom edges.
  • .container — the full-screen stage the tiles live in; overflow: hidden so tiles that scale up 5× are clipped to the viewport.
  • .gallery > .item ×15 — the tiles. Each starts stacked at the dead center of the container; JS gives each one an <img> and animates it out onto the ring.

Styling

Reset: * { margin:0; padding:0; box-sizing:border-box; }

Page / typography

html, body { width:100%; height:100vh; font-family:"Timmons NY", sans-serif; }

Use a bold condensed display / grotesque font for the nav & footer (fall back to sans-serif). All chrome text is uppercase.

Fixed chrome (nav + footer)

nav, footer {
  position:fixed; width:100%;
  display:flex; justify-content:space-between;
  padding:1em; text-transform:uppercase; font-size:30px;
}
nav    { top:0; }
footer { bottom:0; }

Two lines each, pushed to the far left and far right (space-between). Neutral placeholder copy — no real brand marks.

Stage

.container { position:relative; width:100%; height:100%; overflow:hidden; }

Tile

.item {
  position:absolute;
  top:50%; left:50%;
  transform:translate(-50%,-50%);   /* every tile starts centered */
  width:70px; height:100px;         /* portrait 7:10 */
  background:#b0b0b0;               /* grey placeholder shown until the img paints */
  margin:10px;
}
img { width:100%; height:100%; object-fit:cover; }  /* fills the tile, center-cropped */

Key facts the effect depends on: tiles are absolutely positioned, all initially anchored at top:50% / left:50% with a translate(-50%,-50%) (so they overlap in a stack at the center before animating), they are 70×100px portrait rectangles, and the grey #b0b0b0 is just the pre-load fill behind each image.

No page background color is set (default white). There is no other styling — the ring of photos *is* the page.

The GSAP effect (be exhaustive — this is the whole component)

Everything runs from a single window.onload handler. There is one entrance timeline on load and imperative gsap.to() tweens on click. Reproduce the constants, geometry and tween params exactly.

Setup constants
const items          = document.querySelectorAll('.item');   // 15 tiles
const container      = document.querySelector('.container');
const numberOfItems  = items.length;                         // 15
const angleIncrement = (2 * Math.PI) / numberOfItems;        // even angular spacing around the full circle
const radius         = 300;                                  // ring radius in px
let   isGalleryOpen  = false;                                // guards click state

const centerX = container.offsetWidth  / 2;                  // pixel center of the stage
const centerY = container.offsetHeight / 2;

const tl = gsap.timeline();                                  // the entrance timeline
Per-tile geometry (computed in a forEach(item, index) loop)

For each tile, inject its image (const img = document.createElement('img'); img.src = <indexed path, e.g. img{index+1}.jpg>; item.appendChild(img);), then compute its resting place on the ring:

const angle           = index * angleIncrement;               // 0 → 2π across the 15 tiles
const initialRotation = (angle * 180 / Math.PI) - 90;         // tile's tilt IN DEGREES: radian angle → deg, minus 90
const x = centerX + radius * Math.cos(angle);                 // target left (px) on the circle
const y = centerY + radius * Math.sin(angle);                 // target top  (px) on the circle

The −90° rotation makes each tile's long (vertical) axis line up with its radial spoke, so the ring reads like petals / the numerals on a clock face fanning around the center.

Entrance animation (on load) — the star moment

Before animating, hard-set every tile to invisible size: gsap.set(item, { scale: 0 });. Then add each tile's tween to the shared timeline at an explicit position so they cascade:

tl.to(item, {
  left: x + 'px',              // 50% (center) → its ring X
  top:  y + 'px',              // 50% (center) → its ring Y
  rotation: initialRotation,   // 0 → its radial tilt
  scale: 1,                    // 0 → 1 (grows in)
  duration: 1,
  ease: "power2.out",
  delay: 1,                    // each tween also waits 1s
}, index * 0.1);               // ← timeline position param = 0.1s stagger between tiles

Critical details:

  • The **stagger is produced by the position parameter index * 0.1** (not GSAP's stagger option): tile 0 is placed at t=0, tile 1 at t=0.1, … tile 14 at t=1.4 on the timeline.
  • Each tween additionally carries delay: 1, so the first tile actually starts moving at ~1s and the last finishes at ~1.4 + 1(delay) + 1(dur)3.4s. Budget ~3s before the ring is settled.
  • All four properties animate together per tile: it scales 0→1, slides from screen-center to its (x,y) on the ring, and rotates 0→initialRotation, on a power2.out (fast start, soft settle). Net look: tiles burst out of the middle and spin into a clean circle one after another.
Click a tile → focus / zoom to center

Attach a click listener to each tile (inside the same loop, so it closes over that tile's x, y, initialRotation). On click, only if !isGalleryOpen:

  1. Set isGalleryOpen = true.
  2. Clone the clicked tile: const duplicate = item.cloneNode(true); duplicate.style.position = 'absolute'; container.appendChild(duplicate); — the original and this duplicate are animated together as a pair for the rest of the focus sequence.
  3. Shrink all the other tiles away:

``js gsap.to(Array.from(items).filter(i => i !== item), { scale: 0, duration: 0.5, ease: "power2.in", stagger: 0.05 }); ``

  1. Normalize the rotation so the focus move takes the short way round, then snap it instantly:

``js const endRotation = initialRotation > 180 ? initialRotation - 360 : initialRotation; gsap.to([item, duplicate], { rotation: endRotation, duration: 0.0001, // effectively instant onComplete: () => { gsap.to([item, duplicate], { left: "50%", top: "50%", transform: "translate(-50%, -50%) scale(5)", // ← animated as a raw CSS transform string, scale ×5 duration: 1, ease: "power2.out", delay: 1.25 // waits 1.25s (lets the others finish collapsing) before flying in }); } }); ` So after a ~1.25s beat the chosen tile (and its clone, stacked on top) glides back to dead-center and scales up 5×, filling the stage as a hero image (clipped by the container's overflow:hidden`).

Click again → restore the ring

Define a closeGallery handler and attach it to both the original tile and the duplicate (item.addEventListener('click', closeGallery); duplicate.addEventListener('click', closeGallery);). On this second click, if isGalleryOpen:

gsap.to([item, duplicate], {
  left: x + 'px',                 // back to its ring X
  top:  y + 'px',                 // back to its ring Y
  scale: 1,                       // 5 → 1
  rotation: initialRotation,      // back to its radial tilt
  duration: 1,
  ease: "power2.out",
  onComplete: () => {
    duplicate.remove();           // discard the clone
    gsap.to(items, {              // pop every tile back in
      scale: 1, duration: 1, stagger: 0.05, ease: "power2.out"
    });
    isGalleryOpen = false;        // re-arm for the next click
  }
});

Net: the hero tile shrinks and travels back to its slot on the ring, the clone is removed, and all the collapsed tiles scale 0→1 back into place with a 0.05s stagger.

Motion summary
  • Entrance: power2.out, per-tile duration:1, delay:1, positioned at index*0.1 → radial burst that settles in ~3.4s.
  • Collapse others: power2.in, duration:0.5, stagger:0.05.
  • Focus in: power2.out, duration:1, delay:1.25, scale ×5 to center.
  • Restore: power2.out, duration:1; then others back with stagger:0.05, duration:1, power2.out.

Assets / images

15 images, each filling the same role — one photo per tile in the ring, injected via an indexed path pattern (…/img1.jpg…/img15.jpg). Displayed in a 70×100px portrait tile (~7:10, close to 2:3) with object-fit:cover, so they are center-cropped and, in focus mode, scaled up 5× to roughly fill the screen — so the central subject and tonal contrast matter more than resolution.

Curate an eclectic but tonally cohesive, moody art-directed editorial set: a mix of cinematic figures (a lone silhouette walking toward light between towering slabs; an astronaut before a giant pale sphere in golden haze; a hand pressed to backlit frosted glass), fashion/beauty portraits (low-key studio headshots, a face behind a pale tulle veil, oversized futuristic wraparound sunglasses on a warm ground), surreal 3D renders (a chrome robotic figure in mirrored shades, a smooth faceless figure lit green), still lifes (red roses on black; a golden lucky-cat figurine in a red niche), and atmospheric landscapes (a hazy sunset skyline; a figure on calm water at dusk). Dominant palette: deep teals, warm oranges/golds, moody blues, greys and blacks with occasional saturated accents. Portrait, center-croppable framing. No real brand imagery or client logos.

Behavior notes

  • Trigger model: entrance is on window.onload; focus/restore is click (toggle). No scroll, hover, mousemove, or keyboard interaction; no ScrollTrigger.
  • Single-focus lock: isGalleryOpen guards the state — while one tile is focused, clicking other tiles does nothing until you click the focused tile (or its clone) to restore.
  • Geometry is measured at load from container.offsetWidth/offsetHeight; the ring is not recomputed on resize (no resize handler).
  • The clone quirk is intentional: the focused tile is duplicated and the pair is animated together; the clone is removed only on restore. Reproduce it as-is.
  • No reduced-motion branch, no infinite loops or timers — motion only occurs on load and on click. Lightweight and mobile-safe (15 tiles, no WebGL/canvas).