# Rotating Hand Animation — pinned scroll hero with a spinning clock-hand rod

## Goal

Build a full-page scroll-driven hero: the section pins for 8 viewport heights while a thin vertical rod (like a clock hand) spins 5 full turns (1800°) around the center of the screen, driven directly by scroll progress. Each completed 360° cycle swaps the intro headline text. On the 4th cycle a portrait photo fades in inside the rod and two paragraphs slide in. In the final stretch the rod grows to full height, scales up 20×, fades out, and a giant brand wordmark is revealed underneath.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) with the `ScrollTrigger` plugin, plus `lenis` for smooth scrolling. No other libraries.

## Layout / HTML

- `<section class="sticky">` — the pinned hero. Inside it, three absolutely-positioned layers:
  - `<div class="hand-container">` containing `<div class="hand">` which contains a single `<img>` (the portrait).
  - `<div class="intro">` containing `<h1><span>time to</span> be brave</h1>` followed by two `<p>` elements of placeholder lorem-ipsum copy (2–3 lines each).
  - `<div class="website-content">` containing `<h1>harrnish</h1>` (the brand wordmark).
- `<section class="about">` after it — a simple white section with a centered paragraph like "(Your next section goes here)" so there is somewhere to scroll to after the pin releases.
- Module script tag at the end of `<body>`.

## Styling

- Global reset (`* { margin:0; padding:0; box-sizing:border-box }`), antialiased font smoothing.
- `html, body`: `width: 100vw; height: 1000vh` (tall page), font-family "PP Neue Montreal", sans-serif fallback, color `#fff`.
- Every `section`: `width: 100vw; height: 100vh`.
- `.sticky`: background `#161616` (near-black).
- `.about`: white background (`#ffffff`), flex-centered content, its `p` in black.
- `h1`: `font-size: 30px; font-weight: 500; letter-spacing: -0.01em`. The `h1 span` is muted grey `#6e6e6e` (so "time to" is grey and the rest is white).
- `p`: `font-size: 16px; font-weight: 500; color: #555555; text-align: justify; line-height: 130%`.
- `img`: `width: 100%; height: 100%; object-fit: cover; opacity: 0` (hidden by default — revealed later by GSAP).
- `.hand-container`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)`, `width: 800px; height: 800px`, `display: flex; justify-content: center; align-items: flex-start` (the rod hangs from the top edge of the container so rotating the container swings it like a clock hand), `transform-origin: center center; transform-style: preserve-3d; will-change: transform; z-index: 2`.
- `.hand`: `position: absolute; width: 5.5%; height: 52.75%` (a thin rod slightly longer than the container's radius), `background-color: rgb(238, 238, 238)` (off-white), `border-radius: 1000px` (fully pill-shaped), `overflow: hidden; will-change: transform; opacity: 1`.
- `.intro`: `position: absolute; top: calc(50% - 20px); left: 25%; width: 22.5%`.
- `.intro p`: `position: relative; margin-top: 0.75em; transform: translateX(20px); opacity: 0` (hidden, offset 20px right — GSAP slides them in).
- `.website-content`: `position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; opacity: 0`. Its `h1` is huge: `font-size: 10vw; letter-spacing: -0.03em`.
- Include the standard Lenis helper CSS (`.lenis.lenis-smooth { scroll-behavior: auto !important; }`, `[data-lenis-prevent] { overscroll-behavior: contain; }`, `.lenis.lenis-stopped { overflow: hidden; }`, `.lenis.lenis-smooth iframe { pointer-events: none; }`).
- Media query `max-width: 900px`: `.intro { width: 35%; left: 2em; }`, `h1 { font-size: 18px; }`, `p { font-size: 13px; }`.

## GSAP effect (be exact)

Register `ScrollTrigger`. Everything runs inside a `DOMContentLoaded` listener.

**Lenis setup:** `const lenis = new Lenis()`; `lenis.on("scroll", ScrollTrigger.update)`; drive it from GSAP's ticker: `gsap.ticker.add((time) => lenis.raf(time * 1000))`; `gsap.ticker.lagSmoothing(0)`.

**Single ScrollTrigger** (no timeline, no scrub property — all animation is computed manually in `onUpdate` from `self.progress`):

- `trigger: .sticky`, `start: "top top"`, `end: "+=" + (window.innerHeight * 8)` (pinnedHeight = 8 viewport heights), `pin: true`, `pinSpacing: true`.

**Headline cycling state:** an array of 5 headline HTML strings (each keeps the grey `<span>` prefix):
1. `<span>time to</span> be brave`
2. `<span>time to</span> be playful`
3. `<span>time to</span> design the future`
4. `<span>time to</span> meet harrnish`
5. `<span>time to</span> see project one`

Track `currentCycle` (init `-1`) and `imageRevealed` (init `false`). An `updateHeaderText()` helper sets `h1Element.innerHTML = introHeaders[Math.min(currentCycle, introHeaders.length - 1)]` and re-queries the `span` reference (it is replaced by innerHTML). Call `updateHeaderText()` once on load after creating the ScrollTrigger so the first headline renders.

**Inside `onUpdate(self)`, with `progress = self.progress` (0→1 across the 8-viewport pin):**

1. **Rotation (occupies the first 5/8 of the pin):**
   - `rotationProgress = Math.min((progress * 8) / 5, 1)` — rotation completes at progress 5/8 and clamps at 1 after.
   - `totalRotation = rotationProgress * 1800 - 90` — 5 full turns (1800°), starting at −90° (rod pointing horizontally).
   - `rotationInCycle = ((totalRotation + 90) % 360) - 90` — wraps to the current cycle's angle.
   - `gsap.set(handContainer, { rotationZ: rotationInCycle })` — set directly every frame (scrubbed by scroll, no easing).

2. **Cycle detection / headline swap:** `newCycle = Math.floor((totalRotation + 90) / 360)`. When `newCycle !== currentCycle` and `0 <= newCycle < 5`, store it and call `updateHeaderText()`. Additionally:
   - When entering cycle 3 (and `!imageRevealed`): `gsap.to(handImage, { opacity: 1, duration: 0.3 })` and `gsap.to(introCopy, { x: 0, opacity: 1, duration: 0.5, stagger: 0.1 })` (the two paragraphs slide from x:20 to 0). Set `imageRevealed = true`.
   - When leaving cycle 3 (any other cycle while `imageRevealed`): reverse it — `gsap.to(handImage, { opacity: 0, duration: 0.3 })`, `gsap.to(introCopy, { x: 20, opacity: 0, duration: 0.5, stagger: 0.1 })`, `imageRevealed = false`.
   - These four tweens are the only time-based tweens; everything else is `gsap.set` scrubbed from progress.

3. **Rod stretch + headline fade (progress 5/8 → 6/8):** while `progress <= 6/8`:
   - `animationProgress = Math.max(0, (progress - 5/8) / (1/8))`.
   - Height: `gsap.utils.interpolate(52.75, 100, animationProgress)` → `gsap.set(hand, { height: newHeight + "%" })` (rod grows to full container height).
   - `gsap.set(intro, { opacity: 1 })` and fade the h1 and its span: `gsap.utils.interpolate(1, 0, animationProgress)` applied via `gsap.set` to both `h1Element` and `h1Span`.
   - Else (progress > 6/8): `gsap.set(intro, { opacity: 0 })`.

4. **Rod scale-up (progress 6/8 → 7/8):** while `progress <= 7/8`: `scaleProgress = Math.max(0, (progress - 6/8) / (1/8))`, `newScale = gsap.utils.interpolate(1, 20, scaleProgress)`, `gsap.set(hand, { scale: newScale })` — the rod blows up to 20× and floods the screen.

5. **Rod fade-out (progress 7/8 → 7.5/8):** while `progress <= 7.5/8`: `opacityProgress = Math.max(0, (progress - 7/8) / (0.5/8))`, opacity `gsap.utils.interpolate(1, 0, opacityProgress)` set on `.hand`.

6. **Wordmark reveal (progress 7.5/8 → 1):** when `progress > 7.5/8`: `revealProgress = (progress - 7.5/8) / (0.5/8)`, `gsap.set(".website-content", { opacity: gsap.utils.interpolate(0, 1, revealProgress) })`. Otherwise keep `.website-content` at `opacity: 0`.

All the `gsap.set` phases are fully reversible by scrolling back up because every value derives from `progress`.

## Assets / images

- 1 image: a black-and-white portrait photograph of a person, roughly portrait/square aspect. It sits inside the thin rod (`object-fit: cover`, mostly cropped by the rod's pill shape) and only becomes visible during the 4th rotation cycle.

## Behavior notes

- The effect is scroll-scrubbed and reversible; there is no autoplay.
- The page is intentionally very tall (`height: 1000vh` on body plus pin spacing) so the pin has room.
- Works on mobile (media query shrinks type and repositions the intro), no WebGL/canvas.
- The rod starts pointing horizontally (−90°) and sweeps clockwise like a clock hand as the user scrolls.
