All components

Inkwell-Inspired Image Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

A ring of 25 small cards arranged in a circle that reacts to cursor proximity: nearby cards flip 180deg on rotationY, scale up and push outward via per-frame lerp, while the whole ring parallax-tilts (rotateX/Y) toward the mouse. Clicking a card runs a GSAP power4 timeline that spins and scales the gallery 5x downward into a full preview and reveals the card title with a SplitText word stagger; clicking anywhere or pressing Escape reverses it.

How it's built

Categorygallery
Techgsap
GSAP pluginsSplitText
Complexitypage
Performance costmedium
Mobile-safedesktop-first

gallery circular gsap split-text hover parallax 3d-flip lerp interactive

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 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 .card elements 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 }).
  • body background: #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").
  • nav and footer: position: absolute (nav top: 0, footer bottom: 0), left: 0, width: 100vw, padding: 2em, flex row with justify-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 position x = 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). Store data-index = i and data-title on 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). If distance < 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.15 for X, Y, Z; then gsap.set(galleryContainer, { rotateX: currentX, rotateY: currentY, rotation: currentZ, transformOrigin: "center center" }).
  • For each card, lerp currentRotation, currentScale, currentX, currentY toward 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:

  1. Gallery zoomgsap.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 its onStart, 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).
  2. Parallax settlegsap.to(parallaxState, { currentX: 0, currentY: 0, currentZ: 0, duration: 0.5, ease: "power2.out" }) with an onUpdate that applies the values to galleryContainer via gsap.set (the rAF loop is paused by the flags, so this tween drives the container back to level).
  3. Title reveal — create a <p> with the card's data-title, append to .title-container, keep a reference in currentTitle. new SplitText(p, { type: "words", wordsClass: "word" }); gsap.set(words, { y: "125%" }) then gsap.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 clearing currentTitle on 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 = false and 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 < 1000 disables 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: hidden on the <img> plus transformPerspective on 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.