# Text Hover Image Ring

## Goal
Build a full-screen black editorial hero: one large centered agency paragraph in which **four keywords are highlighted in a pixel/raster display font**. The star effect is a **hover reveal**: when the cursor enters one of those highlighted words, **a ring of 11 image tiles fades in and continuously orbits around the cursor** while lerp-following it, and the rest of the paragraph dims to grey so the hovered word stays bright. Move off the word and the ring fades back out. The orbit and follow are entirely GSAP-driven (core `gsap` + `gsap.ticker`), no plugins.

## Tech
Vanilla HTML/CSS/JS shipped as `index.html` + `styles.css` + an ES-module `script.js` (`<script type="module" src="./script.js">`). Use only **`gsap` (npm) core — no GSAP plugins, no ScrollTrigger, no SplitText, no Lenis, no Three.js.** The motion is: `gsap.to` tweens on each tile plus a global `gsap.ticker.add` rAF loop and a hand-rolled lerp. Must run in a fresh Vite + npm project. Import shape:
```js
import gsap from "gsap";
```

## Layout / HTML
```html
<body>
  <div class="hero-copy">
    <p>
      We are a digital agency. From concept to <span>execution</span>, we
      design experiences, <span>products</span> and services that help our
      international <span>clients</span> create emotional and meaningful
      <span>connections</span> between their brand and people.
    </p>
  </div>

  <nav>
    <p>Motionprompts &nbsp;&nbsp;&nbsp;&nbsp; / &nbsp;&nbsp;&nbsp;&nbsp;26092017</p>
    <p>Youtube</p>
  </nav>

  <footer>
    <p>Subscribe</p>
    <p>MotionpromptsPRO</p>
  </footer>

  <div class="container">
    <div class="gallery">
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
    </div>
  </div>

  <script type="module" src="./script.js"></script>
</body>
```
Key points the JS/CSS depend on:
- Exactly **four `<span>`** inside the single hero `<p>`, wrapping the words **execution, products, clients, connections** (in that order). These are the only hover targets.
- **Exactly 11 empty `.item` divs** inside `.container > .gallery`. JS injects one `<img>` into each (they start empty).
- `nav` (fixed top) and `footer` (fixed bottom) are corner chrome only, two `<p>` each. Use the neutral demo copy above — no real brand names.

## Styling

**Reset / global**
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `html, body { width:100%; height:100vh; font-family:"Timmons NY", sans-serif; background:#000; }` — near-black stage. "Timmons NY" is a tall condensed display face used only by the chrome; any condensed display sans is an acceptable fallback.
- `img { width:100%; height:100%; object-fit:cover; }` — tiles center-crop their image.

**Corner chrome** — `nav, footer { position:fixed; width:100%; display:flex; justify-content:space-between; padding:1em; text-transform:uppercase; font-size:30px; color:#fff; }`. `nav { top:0; }`, `footer { bottom:0; }`. Two `<p>` each, pushed to the edges.

**Ring stage** — `.container { position:relative; width:100%; height:100%; overflow:hidden; pointer-events:none; }` and `.gallery { pointer-events:none; }`. **`pointer-events:none` on both is essential**: the container sits above the hero paragraph in the DOM, so the ring must let mouse events pass through to the `<span>`s underneath — otherwise the tiles would swallow the hover and the effect could never trigger.

**Tiles** — `.item`:
```css
pointer-events: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 70px;
background: #b0b0b0;   /* placeholder grey before the image paints */
margin: 10px;
opacity: 0;            /* start invisible; GSAP fades them in on hover */
```
Every tile is a **100×70px (≈10:7 landscape) rectangle stacked on the container's exact center**; its position on the ring comes entirely from GSAP `x`/`y` (see below). Starts at `opacity:0`.

**Hero paragraph** — `.hero-copy { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }` (dead-center block). `.hero-copy p { font-size:50px; font-family:"FK Grotesk Neue"; color:#fff; text-align:center; letter-spacing:-0.04em; line-height:110%; }` — a tight, centered grotesk. Fall back to any clean grotesk sans (e.g. system sans) if unavailable.

**Highlighted words** — `.hero-copy p span { cursor:pointer; font-family:"FK Raster Grotesk"; position:relative; z-index:1; display:inline-block; }` plus `.hero-copy p span:hover { color:#fff; }`. The **distinctive typographic contrast is the star of the styling**: the four keywords render in a **pixel/dot-matrix "raster" grotesk display font**, visibly coarser than the smooth grotesk of the surrounding sentence. If that exact face isn't available, use any pixel/bitmap or blocky raster display font for the spans so they read as a different, chunkier typeface. `position:relative; z-index:1` keeps the hovered word painting above its dimmed siblings.

## GSAP effect (exhaustive — this is the whole component)

Wrap everything in `window.onload = function () { … }`. No plugins, no `registerPlugin`. The snippets below are a **reference implementation, not a mandated copy-paste** — you may restructure the code, but treat every literal number, easing, duration, and event target as load-bearing: they define the motion and must be reproduced exactly (`radius:300`, lerp `0.1`, spin `+= 0.005`, tween `duration:0.5` / `ease:"power1.out"`, the `clientX-800`/`clientY-450` offsets, and the `-item.offsetWidth/2` / `-item.offsetHeight/2` recenter).

### 1. Constants and state
```js
const items = document.querySelectorAll(".item");          // 11 tiles
const container = document.querySelector(".container");
const spans = document.querySelectorAll(".hero-copy span"); // 4 keywords
const numberOfItems = items.length;                         // 11
const angleIncrement = (2 * Math.PI) / numberOfItems;       // ≈ 0.5712 rad (32.7°) between neighbours
const radius = 300;                                         // ring radius in px
let currentAngle = 0;                                       // advances every frame (continuous spin)
let isMouseOverSpan = false;
let targetX = 0, targetY = 0;                               // ring-center target (lerp destination)
let currentX = 0, currentY = 0;                             // ring-center actual (lerped)
```

### 2. Inject the 11 images
On load, loop the 11 `.item` divs and append one `<img>` to each, sourced from a `basePath + "img" + (index + 1) + ".jpg"` pattern with `alt = "Image " + (index + 1)`:
```js
const basePath = "/img/";   // ← placeholder: point at YOUR image folder (see note)
items.forEach((item, index) => {
  const img = document.createElement("img");
  img.src = basePath + "img" + (index + 1) + ".jpg";   // img1.jpg … img11.jpg
  img.alt = "Image " + (index + 1);
  item.appendChild(img);
});
```
Tile *index* uses `img{index+1}.jpg`, so the 11 tiles map 1:1 to `img1`…`img11`, in order.

> **Image paths are placeholders — you must repoint them.** `basePath` and the `img1.jpg…img11.jpg` filenames are just a naming convention from the reference repo; there is no `/c/text-hover-effect/` folder in a fresh project. Set `basePath` to wherever your 11 images actually live so they resolve — e.g. a Vite `public/` path like `/img/`, a relative `./assets/`, or imported module URLs. **If the sources 404 the ring still orbits but every tile shows only the flat `#b0b0b0` placeholder grey**, so the image-reveal effect looks broken. Supply 11 images (any that match the aspect/mood in *Assets*) named `img1.jpg…img11.jpg` (or adjust the pattern), and the ring fills with pictures as intended.

### 3. `updateGallery(mouseX, mouseY, show = true)` — lerp-follow + place the ring
```js
const updateGallery = (mouseX, mouseY, show = true) => {
  targetX = mouseX - container.getBoundingClientRect().left;
  targetY = mouseY - container.getBoundingClientRect().top;

  currentX += (targetX - currentX) * 0.1;   // lerp factor 0.1 → smooth trailing follow
  currentY += (targetY - currentY) * 0.1;

  items.forEach((item, index) => {
    const angle = currentAngle + index * angleIncrement;
    const x = currentX + radius * Math.cos(angle) - item.offsetWidth / 2;   // −50
    const y = currentY + radius * Math.sin(angle) - item.offsetHeight / 2;  // −35
    gsap.to(item, {
      x: x,
      y: y,
      opacity: show ? 1 : 0,
      duration: 0.5,
      ease: "power1.out",
    });
  });
};
```
Details that matter:
- The container is full-bleed, so its `getBoundingClientRect().left/top` are effectively `0` — `targetX/targetY` are basically the raw values passed in.
- **`currentX/currentY` chase the target with a lerp of 0.1 per call** — the ring center trails the cursor with an easy, elastic-feeling lag rather than snapping.
- Each tile is placed on a **circle of radius 300** around `(currentX, currentY)`: `x = currentX + 300·cos(angle) − 50`, `y = currentY + 300·sin(angle) − 35` (the −50 / −35 recenter each 100×70 tile on its point). `angle = currentAngle + index·angleIncrement` spaces the 11 tiles evenly and rotates them all by the shared, ever-advancing `currentAngle`.
- Every tile is tweened with **`gsap.to`, `duration:0.5`, `ease:"power1.out"`**, and its **`opacity` goes to 1 when `show` is true, 0 when false** — that's the fade-in on enter / fade-out on leave.

### 4. Per-span hover handlers (the trigger)
```js
spans.forEach((span) => {
  span.addEventListener("mouseenter", (e) => {
    isMouseOverSpan = true;
    updateGallery(e.clientX, e.clientY, true);   // ring appears at the cursor
  });
  span.addEventListener("mousemove", (e) => {
    if (isMouseOverSpan) {
      targetX = e.clientX - 800;                 // hard-coded offsets (~half a 1600×900 reference viewport)
      targetY = e.clientY - 450;
    }
  });
  span.addEventListener("mouseleave", () => {
    isMouseOverSpan = false;
    updateGallery(0, 0, false);                  // target (0,0) + opacity 0 → tiles fade out toward top-left
  });
});
```
- **`mouseenter`**: flip `isMouseOverSpan` on and place the ring at the raw cursor position with tiles fading to opacity 1.
- **`mousemove`**: while over a span, keep re-aiming the ring's `targetX/targetY` at **`clientX − 800`, `clientY − 450`** (reproduce these literal constants — they're the original's fixed reference-viewport offsets, not a calculated center).
- **`mouseleave`**: flip the flag off and call `updateGallery(0, 0, false)` once — target snaps toward the top-left origin and all tiles tween to `opacity:0` over 0.5s.

### 5. The `gsap.ticker` loop (continuous spin + follow)
```js
gsap.ticker.add(() => {
  currentAngle += 0.005;                    // advance the shared ring angle every frame
  if (currentAngle > 2 * Math.PI) currentAngle -= 2 * Math.PI;
  if (isMouseOverSpan) {
    updateGallery(targetX, targetY, true);  // re-lerp + reposition every frame while hovering
  }
});
```
- `currentAngle` increases **0.005 rad per frame** (~0.3 rad/s at 60fps → one full revolution roughly every ~21s), giving the ring its slow, endless clockwise orbit. It advances **every frame regardless of hover**, so the ring is at a fresh rotation each time you re-enter a word.
- **Only while `isMouseOverSpan`** does the ticker call `updateGallery(targetX, targetY, true)` — that's what drives the per-frame lerp toward the target and the 0.5s tweens, so the orbiting ring smoothly chases the cursor. When not hovering, the ticker keeps spinning `currentAngle` but issues no tweens and the tiles rest invisible (opacity 0).

### 6. Dim-the-paragraph handler (second listener set)
A **separate** `forEach` over the same spans changes the surrounding text color:
```js
document.querySelectorAll(".hero-copy span").forEach((span) => {
  span.addEventListener("mouseenter", () => { span.parentNode.style.color = "#545454"; });
  span.addEventListener("mouseleave", () => { span.parentNode.style.color = "#fff"; });
});
```
On enter, the parent `<p>` turns **`#545454`** (mid grey), dimming the whole sentence; because the hovered `span` has its own `:hover { color:#fff; }` and `z-index:1`, **only the active keyword stays white** — the rest of the paragraph recedes. On leave, the paragraph returns to `#fff`.

## Assets / images
**11 orbiting ring images** (`img1.jpg` … `img11.jpg`), one per tile, all center-cropped to **100×70px (≈10:7 landscape)** via `object-fit:cover`. Use a cohesive but eclectic **moody, cinematic, experimental-editorial** set — high-contrast and atmospheric. A representative mix (any similar images work — do **not** use real brand imagery or logos):
- cinematic / atmospheric scenes: a lone silhouetted figure walking on a reflective surface toward a bright light; an astronaut in a hazy amber landscape beneath a giant pale planet; a close-up of a helmet with a molten-gold reflective visor;
- moody low-key **portraits**: a blonde woman against a dark smoky backdrop; a woman with wet hair pushed back, face partly hidden; a dim slot-framed lower face holding a cigarette; a figure with a pale-blue tulle veil over the head; a hand pressed to backlit frosted glass;
- **surreal 3D render**: a chrome robotic figure in sunglasses and a mosaic conical hat;
- **still-life / cityscape**: a golden lucky-cat figurine in a red circular niche; a hazy orange sunset skyline of high-rise towers.

Because the tiles render small, overall tone and contrast matter far more than resolution. Provide **exactly 11** distinct images in this order (tile *index* → `img{index+1}.jpg`); if you have fewer, repeat to reach 11. These are supplied by you — the prompt does not ship them — so make sure the `basePath` in the JS points at them (see *GSAP effect §2*), otherwise the ring shows blank grey tiles.

## Behavior notes
- **Desktop / pointer only.** The entire effect is driven by `mouseenter` / `mousemove` / `mouseleave` on the four keywords — there is **no touch handling and no reduced-motion branch**, so it is not mobile-safe as authored.
- **Continuous rAF spin.** The `gsap.ticker` loop runs for the life of the page, always advancing `currentAngle`; it only repositions/fades the tiles while a keyword is hovered.
- **Everything eases, nothing snaps.** The ring center lerps toward its target at 0.1 per frame, and each tile tween is `duration:0.5, ease:"power1.out"`, so the ring trails and settles behind the cursor.
- `pointer-events:none` on `.container` and `.gallery` is load-bearing: the ring sits above the text but must never intercept the hover that triggers it.
- Wrap the whole script in `window.onload` (not `DOMContentLoaded`) so the tiles exist and are measurable (`offsetWidth`/`offsetHeight`) before the ring is positioned.
