# Radial Rotating Text Layout

## Goal

Build a full-screen black editorial page where **60 uppercase text labels are arranged around one enormous circle** (a ring far bigger than the viewport, so only a curved slice of it is ever visible). Each label is rotated to sit tangent to the circle. **Scrolling an extremely tall page slowly rotates the entire ring** with a springy elastic follow. A **custom trailing cursor box** (a 3:4 portrait frame) eases behind the real pointer, and **hovering any label reveals that label's preview image inside the cursor box via a bottom-to-top `clip-path` polygon wipe**; moving off wipes it back out through the top. The star effect is the scroll-driven radial rotation combined with the clip-path image reveal that lives inside the trailing cursor.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use **`gsap`** (npm) — **core only, no plugins**. There is **no ScrollTrigger, no SplitText, no CustomEase, no Lenis, no Three.js**. The scroll rotation is driven by a **plain native `document` `scroll` listener** that feeds `window.scrollY` into a `gsap.to`. All logic runs inside a `DOMContentLoaded` handler. Import as `import gsap from "gsap";`.

## Layout / HTML

```html
<body>
  <div class="cursor"></div>

  <nav>
    <a href="#">Motionprompts <span>/</span> 16 06 2024</a>
    <p>Unlock Source Code with PRO</p>
  </nav>

  <footer>
    <div class="links">
      <a href="#">Subscribe</a>
      <a href="#">Instagram</a>
      <a href="#">Twitter</a>
    </div>
    <p>Link in description</p>
  </footer>

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

  <script type="module" src="./script.js"></script>
</body>
```

- `.cursor` is the trailing preview frame — **empty at start**; hover-preview `<img>` elements are created and appended into it at runtime.
- `.gallery` starts **empty**; the 60 `.item` labels are generated by JS and appended into it.
- `nav` / `footer` are fixed chrome (neutral demo copy — no real brand names).

## Styling

Global reset: `* { margin:0; padding:0; box-sizing:border-box; }`.

- `html, body { width:100%; height:3000vh; background:#000; font-family:"Circular Std", sans-serif; }` — **the 3000vh height is essential**: it makes the page ~30 viewports tall so there is a long scroll range to rotate the ring. Any clean geometric sans fallback is fine.
- `a, p { text-decoration:none; color:#fff; font-size:14px; }`. `a span { padding:0 2em; }`.
- `nav, footer`: `position:fixed; width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:2; mix-blend-mode:difference;`. `nav { top:0; }` `footer { bottom:0; }`. `.links { display:flex; gap:2em; }`. (mix-blend-mode:difference so the chrome inverts against whatever passes behind it.)
- `.cursor { position:fixed; top:0; left:0; width:300px; height:400px; z-index:0; pointer-events:none; }` — a **300×400 (3:4 portrait) box**, pinned top-left and moved via GSAP transforms. `z-index:0` is deliberate: it sits **behind** the gallery text (which has `z-index:auto` and comes later in the DOM), so the revealed preview image renders *behind* the rotating labels.
- `.cursor img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }` (the global `img { … }` rule) — each preview fills the whole 300×400 frame.
- `.gallery { position:fixed; width:200%; height:100%; left:-75%; overflow:hidden; }` — an oversized fixed stage whose origin (its top-left corner, offset to `left:-75%`) is the coordinate origin for the items' `x`/`y` transforms; `overflow:hidden` clips the ring's off-screen arc.
- `.item { position:absolute; top:0; left:0; transform:translate(-50%,-50%); width:800px; height:80px; cursor:pointer; }` — each label box is 800×80, centered on its own anchor point via `translate(-50%,-50%)` before GSAP adds the ring `x`/`y`/`rotation`.
- `.item p { width:100%; font-size:42px; font-weight:500; text-transform:uppercase; color:#fff; }`.
- `.item p span { padding:0 20px; font-size:16px; }` — the small parenthetical count that trails each label.

## The effect (be exhaustive — reproduce every constant)

Everything below runs inside `document.addEventListener("DOMContentLoaded", …)`. Grab `const cursor = document.querySelector(".cursor")` and `const gallery = document.querySelector(".gallery")`.

### Ring constants
```js
const numberOfItems   = 60;
const radius          = 1100;                 // px — far larger than the viewport
const centerX         = window.innerWidth / 2;
const centerY         = window.innerHeight / 2;
const angleIncrement  = (2 * Math.PI) / numberOfItems;   // 60 evenly spaced slots
```

### Build the 60 labels (creation loop `for i = 0 … 59`)
For each `i`:
1. Create `<div class="item">`, a child `<p>`, and a child `<span>`.
2. `p.textContent = names[i]` (see Content), then `count.textContent = "(" + (Math.floor(Math.random()*50)+1) + ")"` → a random integer **1–50**. Append order: `item.appendChild(p); gallery.appendChild(item); p.appendChild(count);` so the markup is `<p>NAME<span>(N)</span></p>`.
3. Compute the item's slot on the ring:
   ```js
   const angle    = i * angleIncrement;
   const x        = centerX + radius * Math.cos(angle);
   const y        = centerY + radius * Math.sin(angle);
   const rotation = (angle * 180) / Math.PI;   // radians → degrees
   ```
4. Place it instantly: `gsap.set(item, { x: x + "px", y: y + "px", rotation: rotation });`
   → labels fan out evenly around the full circle, each rotated to lie **tangent** to the ring (item 0 at 3-o'clock horizontal, rotation growing clockwise).
5. In the same loop, wire this item's `mouseover` / `mouseout` (see Hover reveal), capturing `i` for its image src.

### Scroll rotation — `updatePosition()` (the signature effect)
A single function, called **once on load** and on **every** `document` `scroll` event:
```js
function updatePosition() {
  const scrollAmount = window.scrollY * 0.0001;               // tiny multiplier
  document.querySelectorAll(".item").forEach((item, index) => {
    const angle    = index * angleIncrement + scrollAmount;   // scroll offsets EVERY item's angle
    const x        = centerX + radius * Math.cos(angle);
    const y        = centerY + radius * Math.sin(angle);
    const rotation = (angle * 180) / Math.PI;
    gsap.to(item, {
      duration: 0.05,
      x: x + "px",
      y: y + "px",
      rotation: rotation,
      ease: "elastic.out(1, 0.3)",
    });
  });
}
updatePosition();
document.addEventListener("scroll", updatePosition);
```
- The **`0.0001` multiplier** is intentionally minuscule: because the page is 30 viewports tall, scrolling top→bottom adds only a couple of radians, so the whole ring rotates **slowly** as you scroll. Every label's angle gets the same offset, so the entire ring turns as one rigid wheel.
- Each per-item tween is **`duration: 0.05` with `ease: "elastic.out(1, 0.3)"`** — a short springy settle. Rapid scroll events fire many overlapping tweens that GSAP overwrites, giving a slightly elastic, jittery-alive follow rather than a rigid lock.

### Custom cursor follow — `mousemove` on `document`
```js
document.addEventListener("mousemove", (e) => {
  gsap.to(cursor, {
    x: e.clientX - 150,      // 150 = half of 300 width
    y: e.clientY - 200,      // 200 = half of 400 height
    duration: 1,
    ease: "power3.out",
  });
});
```
- The `-150 / -200` offsets center the 300×400 box on the pointer.
- **`duration: 1`, `ease: "power3.out"`** → the box **lags and trails** the real cursor, easing into place over ~1s. This lag is what makes it read as a floating preview panel chasing the mouse.

### Hover reveal — `mouseover` on each item (bottom-to-top clip-path wipe)
```js
item.addEventListener("mouseover", () => {
  const img = document.createElement("img");
  img.src = `/…/img${i + 1}.jpg`;                                  // per-item preview (i is 0-based → img1…img60)
  img.style.clipPath = "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)";  // all 4 corners at the BOTTOM → zero-height sliver → invisible
  cursor.appendChild(img);
  gsap.to(img, {
    clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)",       // full rectangle → top edge rises up
    duration: 1,
    ease: "power3.out",
  });
});
```
- Start state = a degenerate polygon with **all points on the bottom edge (y=100%)**, so the image is clipped to nothing. The tween drives the two top points up to `y=0%`, so the image **wipes into view from the bottom edge upward** over 1s (`power3.out`).
- The image is appended into `.cursor`, so it appears inside the trailing box and, because `.cursor` is `z-index:0`, it renders **behind** the rotating text.

### Hover out — `mouseout` on each item (wipe up and out)
```js
item.addEventListener("mouseout", () => {
  const imgs = cursor.getElementsByTagName("img");
  if (imgs.length) {
    const lastImg = imgs[imgs.length - 1];
    Array.from(imgs).forEach((img) => {
      if (img !== lastImg) {
        gsap.to(img, {
          clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",     // all corners at the TOP → collapse upward
          duration: 1,
          delay: 0.5,
          ease: "power3.out",
          onComplete: () => setTimeout(() => img.remove(), 1000),  // DOM cleanup 1s after the tween ends
        });
      }
    });
    gsap.to(lastImg, {
      clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)",
      duration: 1,
      ease: "power3.out",
      delay: 0.25,
    });
  }
});
```
- Exit target = all points on the **top edge (y=0%)**, so from the full rectangle the **bottom edge rises to meet the top** → the image **continues wiping upward off the top**. Same `power3.out`, `duration: 1`.
- Cleanup quirk to reproduce faithfully: on each `mouseout`, **every** `<img>` currently in the cursor wipes out. The **most recently added** image (`lastImg`) gets `delay: 0.25` and is **not** removed from the DOM (it lingers, fully clipped/invisible). All **other** images get `delay: 0.5` and are removed 1s after their tween completes. So rapidly sweeping across many labels stacks several clipped-away images in the cursor that get progressively cleaned up on subsequent mouseouts.

## Content

60 short (2–4 word) **fictional space-interior names**, rendered uppercase. They are neutral invented copy — do not use real brands. Reproduce this list in order (item 0 → "Lunar Horizon Lounge", …):

```
Lunar Horizon Lounge, Martian Red Quarters, Orbit Oasis Chamber, Neon Nexus Home,
Quantum Quiet Quarters, Galactic Gateway Studio, Starlight Sky Suite, Void Vector Villa,
Cosmic Cove Nook, Satellite Space Site, Plasma Peak Penthouse, Asteroid Alley Loft,
Celestial City Condo, Pulsar Point Pavilion, Gravity Garden Suite, Interstellar Ivy Inn,
Nebula Nest Nook, Exoplanet Escape Estate, Meteorite Mansion Den, Black Hole Bungalow,
Warp World Workshop, Photon Particle Pod, Dark Matter Den, Event Horizon Home,
Solar Flare Studio, Quantum Leap Lounge, Supernova Sun Suite, Eclipse Edge Enclave,
Galaxy Garden Gazebo, Time Traveler Terrace, Orbital Observatory Outpost, Gravity Grove Grotto,
Cosmos Cottage Core, Space-Time Spiral Studio, Alien Array Atrium, Dimensional Dome Dwelling,
Vortex Valley Villa, Starship Station Studio, Quantum Quasar Quarters, Planetary Plaza Penthouse,
Rocket Range Room, Spectrum Spire Space, Terraforming Tower Terrace, Universe Utopia Unit,
Void Vista View, Wormhole Wall Window, Xenon Xeriscape Xanadu, Yield Yacht Yard,
Zenith Zone Zephyr, Alpha Aurora Atrium, Beta Bridge Bastion, Gamma Garden Gateway,
Delta Dome Den, Epsilon Echo Estate, Zeta Zenith Zone, Eta Echo Enclave,
Theta Theater Thicket, Iota Island Inn, Kappa Keep Kiosk, Lambda Loft Lounge
```

Any set of 60 short uppercase labels works; the sci-fi naming just sets the editorial tone.

## Assets / images

**60 preview files `img1.jpg … img60.jpg`, one per label**, each shown inside the 300×400 (**3:4 portrait**) cursor frame and cropped with `object-fit:cover`. There are only **15 unique images cycled every 15** (file N and file N+15 share content), so you really only need 15 source images repeated. The set is a cohesive **experimental / futuristic / editorial** collection of moody abstract 3D renders and graphic frames on a mostly dark or soft-neutral palette — e.g.:

- greyscale figures whose skin is a swirling topographic contour texture, on a soft grey gradient;
- a caped silhouette against saturated red;
- a molten-lava mountain half-submerged in water (glowing orange peak, dark base);
- a glossy orange sphere orbited by clusters of matte grey spheres on black;
- a scan-line-glitched blue/violet orb on a cyan field;
- an extreme close-up of a large smooth orange sphere fading into black;
- a bulbous metaball form studded with glossy black chrome bubbles on light grey;
- a minimal poster of a red semicircle sun over a blue horizon with vertical motion-blur streaks;
- a warm-orange long-exposure of dancers in heavy motion blur;
- a smeared, melting monochrome 3D head;
- stacked glossy copper fins arcing around a glowing amber core;
- an iridescent liquid-glass swirl with orange/cyan chromatic aberration on a pale ground;
- a monochrome bust covered in topographic line texture, hand on chin;
- a grainy monochrome gradient sphere on a pale ground;
- two dark glossy stone spheres nearly touching on white.

Any ~15 abstract, high-contrast experimental images at ~3:4 work. Do not use real brand imagery.

## Behavior notes

- **Desktop / pointer experience.** The custom cursor and hover reveal require a mouse; there is no touch fallback and no reduced-motion branch in the original. Treat as desktop-only.
- The whole page is one giant fixed stage; there is **no visible scrollbar content** — the 3000vh height exists purely to give `window.scrollY` a long range to rotate the ring. Scroll up/down = spin the wheel of text.
- `updatePosition` runs once immediately (at `scrollY ≈ 0`) so the ring is placed even before any scroll; it re-runs on every scroll event.
- No console errors on load. The fixed `nav`/`footer` sit above everything (`z-index:2`, `mix-blend-mode:difference`); the trailing preview sits below the text (`z-index:0`), giving the layered look where labels overlay their own revealed image.
