# 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 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 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 settle** — `gsap.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.
