Circular Image Gallery with Cursor-Proximity Card Flips and Click-to-Preview Zoom
Goal
Build a full-screen interactive gallery: 25 small photo cards arranged in a perfect circle. As the cursor moves, nearby cards flip over (180° on rotationY), scale up and push radially outward with smooth lerp interpolation, while the whole ring tilts in 3D toward the mouse (parallax). Clicking a card spins and scales the entire ring 5x so the clicked image fills the view like a full-screen preview, and its title animates in word-by-word with SplitText. Clicking anywhere (or pressing Escape) reverses everything back to the ring.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (import SplitText from "gsap/SplitText", then gsap.registerPlugin(SplitText)). No ScrollTrigger, no smooth-scroll library — the page never scrolls. Keep the image data in a separate collection.js module that default-exports an array of { title, img } objects (20 entries).
Layout / HTML
<nav>: an<a>with the brand text "Silhouette Stock" on the left and a<p>"Download Assets" on the right.<div class="container">wrapping:<div class="gallery-container">→<div class="gallery">(empty — the 25.cardelements are created in JS; each card contains one<img>).<div class="title-container">(empty — the preview title<p>is created/removed in JS).<footer>: two<p>elements, "Experiment 454" and "Made by Motionprompts".<script type="module" src="./script.js">at the end of<body>.
Styling
- Global reset (
* { margin:0; padding:0; box-sizing:border-box }). bodybackground:#e3e3db(warm off-white/bone).a, p: color#1f1f1f, font-family "Suisse Intl" (fall back to a clean grotesque sans-serif),font-size: 15px,font-weight: 600,line-height: 1,letter-spacing: -0.02rem, no text decoration.img:width/height: 100%,object-fit: cover,backface-visibility: hidden(so the image disappears when its card flips past 90°, showing "the back").navandfooter:position: absolute(navtop: 0, footerbottom: 0),left: 0,width: 100vw,padding: 2em, flex row withjustify-content: space-between,align-items: center,z-index: 2..container:position: relative,width: 100vw,height: 100svh,overflow: hidden..gallery-container:position: relative,width/height: 100%, flex centering,transform-style: preserve-3d,perspective: 2000px,will-change: transform. This is the element that receives the parallax tilt..gallery:position: relative,width: 600px,height: 600px, flex centering,transform-origin: center,will-change: transform. This is the element that spins/scales on preview..card:position: absolute,width: 45px,height: 60px,border-radius: 4px,transform-origin: center,transform-style: preserve-3d,backface-visibility: visible,overflow: hidden,will-change: transform. All 25 cards start stacked at the center of.gallery; GSAP moves them onto the ring..title-container:position: fixed,bottom: 25%,left: 50%,transform: translate(-50%, -50%),width: 100%,height: 42px,clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)— acts as an overflow mask for the word reveal..title-container p:position: absolute,width: 100%,text-align: center,font-size: 36px,letter-spacing: -0.05rem..word(SplitText word wrapper class):position: relative,display: inline-block,will-change: transform.
GSAP effect (be precise)
Everything runs inside DOMContentLoaded. Keep this config object:
imageCount: 25, radius: 275, sensitivity: 500, effectFalloff: 250,
cardMoveAmount: 50, lerpFactor: 0.15, isMobile: window.innerWidth < 1000
State: three booleans — isPreviewActive, isTransitioning, plus currentTitle (the active title <p> or null). A parallaxState object holds targetX/targetY/targetZ and currentX/currentY/currentZ (all start at 0). A transformState array holds, per card: currentRotation/targetRotation (0), currentX/targetX/currentY/targetY (0), currentScale/targetScale (1), and the card's ring angle.
1. Ring construction (on load)
For each of the 25 cards, index i:
angle = (i / 25) * Math.PI * 2; base positionx = 275 * cos(angle),y = 275 * sin(angle).- The card's image and title come from the 20-item collection using
i % 20(so 5 images repeat). Storedata-index = ianddata-titleon the card. gsap.set(card, { x, y, rotation: angle * 180 / Math.PI + 90, transformPerspective: 800, transformOrigin: "center center" })— the+90°makes every card stand perpendicular to the circle, like ticks on a clock face.- Attach a click handler on the card: if no preview is active and not transitioning, open the preview for that index and
e.stopPropagation()(so the document-level close handler doesn't fire immediately).
2. Mouse-proximity flip + parallax (mousemove on document)
Ignored while isPreviewActive, isTransitioning, or on mobile (innerWidth < 1000). On each mousemove:
- Normalize cursor to viewport center:
percentX = (clientX - centerX) / centerX,percentY = (clientY - centerY) / centerY(each in −1…1). - Parallax targets for the whole container:
targetY = percentX * 15(rotateY, deg),targetX = -percentY * 15(rotateX, deg),targetZ = (percentX + percentY) * 5(z rotation, deg). - Per card: measure the distance from the cursor to the card's center (
getBoundingClientRect). Ifdistance < 500(sensitivity): flipFactor = Math.max(0, 1 - distance / 250)(effectFalloff — so the effect only really kicks in inside 250px and maxes at 1 on top of the card).targetRotation = 180 * flipFactor(rotationY flip, deg).targetScale = 1 + 0.3 * flipFactor(up to 1.3).targetX = 50 * flipFactor * cos(angle),targetY = 50 * flipFactor * sin(angle)— pushes the card radially outward up to 50px (cardMoveAmount).- Otherwise all four targets reset to
0 / 1 / 0 / 0.
Also listen for mouseout to <html>/window (when e.relatedTarget is null or HTML): if idle (no preview, not transitioning), reset every card's targets and the parallax targets to neutral.
3. Per-frame lerp loop (requestAnimationFrame, runs forever)
Each frame, only when !isPreviewActive && !isTransitioning:
- Lerp parallax:
current += (target - current) * 0.15for X, Y, Z; thengsap.set(galleryContainer, { rotateX: currentX, rotateY: currentY, rotation: currentZ, transformOrigin: "center center" }). - For each card, lerp
currentRotation,currentScale,currentX,currentYtoward their targets with the same 0.15 factor, then:
gsap.set(card, { x: baseX + currentX, y: baseY + currentY, rotationY: currentRotation, scale: currentScale, rotation: angleDeg + 90, transformOrigin: "center center", transformPerspective: 1000 }) where baseX/baseY are the ring coordinates recomputed from radius and angle.
4. Click-to-preview (togglePreview)
Set isPreviewActive = true, isTransitioning = true. Compute how far the ring must rotate so the clicked card lands at the "12 o'clock" position: rotationRadians = (3π/2) - cardAngle, normalized into (−π, π] (add/subtract 2π if outside). Reset every card's transformState (targets and currents) to neutral. Then three simultaneous animations:
- Gallery zoom —
gsap.to(gallery, { scale: 5, y: 1300, rotation: rotationRadians * 180/π + 360, duration: 2, ease: "power4.inOut" })(the extra +360° adds a full spin during the zoom).onComplete: isTransitioning = false. In itsonStart, tween every card back to its exact base ring position:{ x: baseX, y: baseY, rotationY: 0, scale: 1, duration: 1.25, ease: "power4.out" }(un-flips any hovered cards while the ring zooms). - Parallax settle —
gsap.to(parallaxState, { currentX: 0, currentY: 0, currentZ: 0, duration: 0.5, ease: "power2.out" })with anonUpdatethat applies the values togalleryContainerviagsap.set(the rAF loop is paused by the flags, so this tween drives the container back to level). - Title reveal — create a
<p>with the card'sdata-title, append to.title-container, keep a reference incurrentTitle.new SplitText(p, { type: "words", wordsClass: "word" });gsap.set(words, { y: "125%" })thengsap.to(words, { y: "0%", duration: 0.75, delay: 1.25, stagger: 0.1, ease: "power4.out" })— words slide up from below the clip-path mask just as the zoom finishes.
5. Close / reset (document click while preview is active, or Escape key)
Guard with isTransitioning. Set isTransitioning = true, then:
- Title exit:
gsap.to(words, { y: "-125%", duration: 0.75, delay: 0.5, stagger: 0.1, ease: "power4.out" }), removing the<p>and clearingcurrentTitleon complete (words exit upward through the mask). - Gallery return:
gsap.to(gallery, { scale: <responsive scale>, x: 0, y: 0, rotation: 0, duration: 2.5, ease: "power4.inOut" }). On complete:isPreviewActive = isTransitioning = falseand zero out the whole parallaxState.
6. Resize handling
On resize (and once on load): recompute isMobile (< 1000), and gsap.set the gallery scale responsively — 0.6 below 768px, 0.8 below 1200px, 1 otherwise. If no preview is active, also reset all parallax and card transform state to neutral. The reset tween in step 5 must use this same responsive scale as its target.
Assets / images
20 unique moody, stylized portrait/silhouette photographs (dramatic lighting, monochrome and duotone tones), portrait orientation 3:4 (cards are 45×60px). They fill the 25 cards cyclically (i % 20). Each has a short two-word evocative title shown in the preview. Use this collection (title → one image each):
"Shadow Profile", "Crimson Silhouette", "Wavelength", "Noir Figure", "Midnight Gaze", "Cobalt Contrast", "Half-Light", "Scarlet Frame", "Pale Vision", "Spectral Form", "Monochrome Motion", "Platinum Edge", "Electric Shade", "Veiled Light", "Luminous Dark", "Haze Portrait", "Glowing Contour", "Dark Elegance", "Ruby Accent", "Clear Gaze".
Behavior notes
- The hover flip/parallax is desktop-only (
window.innerWidth < 1000disables it); the ring still renders and the click-to-preview still works on mobile, just scaled down (0.6 / 0.8). - Cards ignore clicks while a preview is open or a transition is running; the document-level click closes the preview only when it's fully open.
- The rAF loop never stops — it simply skips its work while previewing/transitioning, so hover interactivity resumes seamlessly after closing.
backface-visibility: hiddenon the<img>plustransformPerspectiveon the cards is what makes the 180° flip read as a real card flip (blank back).- No page scroll, no ScrollTrigger; everything is driven by mousemove, click, Escape and rAF.