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 / 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: hiddenso 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'sstaggeroption): 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 apower2.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:
- Set
isGalleryOpen = true. - 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. - 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 }); ``
- 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-tileduration:1,delay:1, positioned atindex*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 withstagger: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:
isGalleryOpenguards 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).