# Text Reveal Animation (per-line char slide-in with skew, three trigger modes)

## Goal
Build a demo page of three full-viewport sections, each centered on one giant uppercase headline, where **every character of the headline slides in from the right with a skew and fades into place**. Each `<h1>` is split into lines / words / chars with GSAP SplitText, and the chars animate `x:100 → 0`, `skewX:20 → 0`, `opacity:0 → 1` on a `power3.out` ease. The signature detail: the per-character stagger is keyed to each char's **index within its own line**, so on a multi-line headline **all lines reveal in parallel** (all first-chars fire together, all second-chars 0.05s later, etc.) rather than one long left-to-right sweep across the whole block. The three sections demonstrate three trigger modes: (1) plays once on page load, (2) replays every time it scrolls into view (and resets when scrolled back above), (3) scrubs forward/back tied to scroll position. Smooth scroll via Lenis.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins `SplitText` and `ScrollTrigger`, and `lenis` (npm) for smooth scroll. No framework, no Three.js, no CustomEase. Imports:
```js
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Lenis from "lenis";
```
Register once at top level: `gsap.registerPlugin(SplitText, ScrollTrigger);`. Note SplitText is a GSAP plugin (bundled with modern gsap npm).

## Layout / HTML
Three `<section>`s, each with a single centered headline. The trigger mode is chosen by a data-attribute on the `<h1>`:

```html
<section class="one">
  <h1 class="animated-header">Lorem ipsum dolor sit</h1>
</section>

<section class="two">
  <h1 class="animated-header" data-animate-on-scroll>Lorem ipsum dolor sit, amet consectetur adipisicing</h1>
</section>

<section class="three">
  <h1 class="animated-header" data-scrub>Lorem ipsum dolor sit amet</h1>
</section>
```

- Every headline carries the class `animated-header` (the JS selects all of them).
- Section one's headline has **no data-attribute** → plays once on load.
- Section two's headline has **`data-animate-on-scroll`** → replays on scroll-enter.
- Section three's headline has **`data-scrub`** → scroll-scrubbed.
- The middle headline is intentionally the **longest** text (wraps to multiple lines at `width:65%`) so the parallel-per-line reveal is visible. Use neutral placeholder copy (e.g. lorem-style phrases); no brand names.

## Styling
Import the font (a heavy condensed sans, all weights + italics):
```css
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap");
```

Palette (CSS variables on `:root`) — three high-contrast dark-bg / neon-fg pairs:
- `--base-100: #23002b` (deep purple)   `--base-200: #ff94c2` (pink)
- `--base-300: #002529` (deep teal)     `--base-400: #94c6ff` (light blue)
- `--base-500: #291900` (deep brown)    `--base-600: #c2ff46` (acid green)

Reset: `* { margin:0; padding:0; box-sizing:border-box; }`

Typography (the display headline):
```css
h1 {
  text-transform: uppercase;
  font-family: "Barlow Condensed", sans-serif;
  font-weight: 900;
  font-size: clamp(3rem, 10vw, 15rem);
  letter-spacing: -2%;
  line-height: 0.75;      /* tight leading — lines pack close */
}
```

Sections (each fills the viewport, centers its headline):
```css
section {
  position: relative;
  width: 100%;
  height: 100svh;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  overflow: hidden;        /* clips the x:100 offset chars while they slide in */
}
section h1 { width: 65%; } /* forces long headlines to wrap to multiple lines */

.one   { background-color: var(--base-100); color: var(--base-200); }
.two   { background-color: var(--base-300); color: var(--base-400); }
.three { background-color: var(--base-500); color: var(--base-600); }
```

SplitText element display rules (these class names must match the SplitText config below):
```css
.line { display: block; }
.word { display: inline-block; }
.char {
  display: inline-block;
  will-change: transform, opacity;
}
```
`overflow:hidden` on the section plus `display:inline-block` chars are what let each char translate horizontally without breaking layout or showing outside the panel.

## GSAP effect (exhaustive — this is the core)

### Smooth-scroll wiring (Lenis driven by the GSAP ticker)
Inside a `DOMContentLoaded` listener:
```js
const lenis = new Lenis({ autoRaf: false });
lenis.on("scroll", ScrollTrigger.update);              // keep ScrollTrigger synced to Lenis
gsap.ticker.add((time) => { lenis.raf(time * 1000); }); // Lenis rAF driven by GSAP ticker (ms)
gsap.ticker.lagSmoothing(0);                           // disable lag smoothing so scrub stays glued
```
`autoRaf:false` — Lenis does not run its own rAF loop; the GSAP ticker drives it.

### Wait for fonts, then init each headline
```js
document.fonts.ready.then(() => {
  document.querySelectorAll(".animated-header").forEach((el) => {
    initAnimatedHeader(el, {
      animateOnScroll: el.hasAttribute("data-animate-on-scroll"),
      scrub: el.hasAttribute("data-scrub"),
    });
  });
});
```
Waiting on `document.fonts.ready` is important — SplitText must measure line breaks against the final loaded font, or char positions will be wrong.

### `initAnimatedHeader(el, options)` — signature & defaults
```js
function initAnimatedHeader(el, {
  animateOnScroll = false,
  scrub = false,
  delay = 0,
  stagger = 0.05,
  duration = 0.65,
} = {}) { … }
```

### Step 1 — Split the headline
```js
const split = SplitText.create(el, {
  type: "lines,words,chars",
  linesClass: "line",
  wordsClass: "word",
  charsClass: "char",
  autoSplit: true,
});
const { chars, lines } = split;
```
`type:"lines,words,chars"` produces nested `.line > .word > .char` wrappers; `autoSplit:true` re-splits automatically if the font/layout changes so line grouping stays correct.

### Step 2 — Set the initial (hidden) state of every char
```js
gsap.set(chars, { x: 100, opacity: 0, skewX: 20 });
```
Each char starts **100px to the right**, **fully transparent**, and **skewed 20°**.

### Step 3 — Build per-line char metadata (the signature detail)
For each line, find only the chars that belong to that line and record each char's index **within its own line**:
```js
const charMeta = lines.flatMap((line) => {
  const lineChars = chars.filter((c) => line.contains(c));
  return lineChars.map((char, charIndexInLine) => ({ char, charIndexInLine }));
});
```
So the first char of *every* line has `charIndexInLine = 0`, the second char of every line has `1`, etc. This is what makes all lines animate in parallel instead of sequentially.

### Step 4 — The `animate(tl)` builder
Add one tween per char onto the timeline, positioning each at an **absolute time** equal to its in-line index × stagger:
```js
const animate = (tl) => {
  charMeta.forEach(({ char, charIndexInLine }) => {
    tl.to(
      char,
      {
        x: 0,
        opacity: 1,
        skewX: 0,
        ease: "power3.out",
        duration,            // 0.65
      },
      charIndexInLine * stagger,   // absolute position on the timeline (index * 0.05s)
    );
  });
  return tl;
};
```
Critical: the third argument is a **number**, an absolute timeline position (not `"+="`). Char N of *any* line is placed at `N * 0.05s`. Result: column 0 of all lines fires at t=0, column 1 at t=0.05, column 2 at t=0.10 … each char taking 0.65s with `power3.out`. Total timeline length = `(maxCharsInLongestLine − 1) * 0.05 + 0.65`.

### Step 5 — Wire the trigger, three modes:

**Mode A — default / on load (section one, no data-attr):**
```js
const tl = gsap.timeline({ delay });   // delay = 0
animate(tl);
```
An un-paused timeline → plays immediately (once fonts are ready). No ScrollTrigger.

**Mode B — `animateOnScroll` (section two, `data-animate-on-scroll`):**
```js
const tl = gsap.timeline({ paused: true, delay });
animate(tl);
ScrollTrigger.create({
  trigger: el,
  start: "top 100%",                 // when the headline's top hits the viewport bottom
  onEnter:     () => tl.restart(),   // (re)play from 0 each time it enters
  onLeaveBack: () => tl.pause(0),    // scrolling back up above it → reset to hidden frame
});
return;
```
Replays on every downward entry; resets to the start (hidden) frame when you scroll back above it.

**Mode C — `scrub` (section three, `data-scrub`):**
```js
const tl = gsap.timeline({ paused: true });
animate(tl);
ScrollTrigger.create({
  trigger: el,
  start: "top 90%",   // begins as the headline nears the bottom of the viewport
  end:   "top 45%",   // fully revealed a bit past center
  scrub: true,        // timeline progress glued to scroll position (reversible)
  animation: tl,
});
return;
```
The reveal is scrubbed: scrolling down plays it forward, scrolling up rewinds it. `start`/`end` map the headline's top from 90% → 45% of the viewport height onto timeline progress 0 → 1.

## Assets / images
None. This component is pure type — no images, SVGs, canvas, or video.

## Behavior notes
- **Desktop and mobile both work**; sizes are all `clamp()`/`vw`/`svh` based. No min-width gate.
- The animation is gated on `document.fonts.ready`, so nothing reveals until the web font has loaded (prevents mis-measured line splits).
- `autoSplit:true` means SplitText re-runs on relevant layout changes; the per-line grouping is recomputed accordingly. (Note: the `charMeta`/timeline is built once at init in the original — it does not rebuild the timeline on resize.)
- No `prefers-reduced-motion` branch in the original.
- All three timelines are one-shot except Mode B, which re-fires on each scroll-enter; Mode C is fully reversible via scrub. No infinite loops.
- Section `overflow:hidden` is load-bearing: it clips the `x:100` starting offset so chars don't spill past the panel edge before they settle.
