# Landing Page Reveal Animation (rolling counter + Flip image reveal + masked line text)

## Goal

Build an auto-playing landing-page intro for a design studio: three vertical rolling digit
columns count from 0 to 100 in the bottom-right corner while a lighter background panel wipes
up from the bottom and 15 rounded photo thumbnails pop in (scale 0 → 1) stacked in the
top-left corner. When the "load" finishes, the counter fades out and GSAP **Flip** animates
every thumbnail from the top-left corner to the bottom-right corner with a staggered
scale-punch (each image blows up to 2.5× mid-flight and settles back to 1). Finally, divider
lines draw in and every piece of text (nav, giant heading, side info, footer) rises into view
line-by-line through overflow masks via **SplitText**. Everything runs once on
`DOMContentLoaded` — no scroll, no hover.

## Tech

Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugins **Flip**
and **SplitText**:

```js
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(Flip, SplitText);
```

No Lenis, no ScrollTrigger — this is a load-triggered, single-viewport hero.

## Layout / HTML

One `<section class="hero">` filling the viewport, containing (in this order):

```html
<section class="hero">
  <div class="hero-bg"></div>

  <div class="counter">
    <div class="counter-1 digit"></div>
    <div class="counter-2 digit"></div>
    <div class="counter-3 digit"></div>
  </div>

  <div class="images-container">
    <!-- 15 of these -->
    <div class="img"><img src="..." alt="" /></div>
  </div>

  <nav>
    <div class="logo-name"><a href="#">Omno</a></div>
    <div class="nav-items">
      <div class="links">
        <a href="#">Portfolio</a>
        <p>/</p>
        <a href="#">About</a>
      </div>
      <div class="cta"><a href="#">Contact Us</a></div>
    </div>
    <div class="divider"></div>
  </nav>

  <div class="sidebar">
    <div class="logo"><img src="..." alt="" /></div>
    <div class="divider"></div>
  </div>

  <div class="header">
    <h1>Visual engineering for modern brands</h1>
  </div>

  <div class="site-info">
    <h2>A design team focused on brands websites, apps and products</h2>
    <div class="divider"></div>
    <div class="site-info-copy">
      <p>Award-winning creative studio</p>
      <p>Operating since 2019</p>
    </div>
  </div>

  <div class="hero-footer">
    <h2>Watch showreel</h2>
  </div>
</section>
```

The three `.counter-*` divs start **empty** — their digit children are generated by JS (see
below).

## Styling

Palette (CSS custom properties):

- `--bg: #f1efe7` — warm off-white page/hero background
- `--fg: #1f1f1f` — near-black text
- `--loader-bg: #e0e0d8` — slightly darker grey-beige for the wipe panel
- `--stroke: rgba(0, 0, 0, 0.2)` — divider lines

Typography: `"PP Neue Montreal"` (import via
`@import url("https://fonts.cdnfonts.com/css/pp-neue-montreal");`), everything weight 500.

- `h1`: `6rem`, `letter-spacing: -0.1rem`, `line-height: 1.1`
- `h2`: `1.75rem`, `letter-spacing: -0.02rem`, `line-height: 1.1`
- `a, p`: `1rem`, `line-height: 1`, `overflow: hidden`, color `--fg`, no underline
- `.logo-name a`: `1.5rem`

Global reset (`* { margin:0; padding:0; box-sizing:border-box }`); all `img` are
`width/height: 100%; object-fit: cover`.

Key positioning (this is what makes the effect work):

- `.hero`: `position: relative; width: 100vw; height: 100svh; background: var(--bg); overflow: hidden`.
- `.hero-bg`: absolutely fills the hero, `background: var(--loader-bg)`,
  `transform-origin: bottom`, initial `transform: scaleY(0%)` — it wipes upward.
- `.counter`: `position: fixed; right: 3rem; bottom: 2rem; display: flex; height: 120px;
  font-size: 120px; line-height: 150px; -webkit-text-stroke: 2px var(--fg);
  clip-path: polygon(0 0, 100% 0, 100% 120px, 0 120px)` — the clip-path is the mask that hides
  digits above/below the 120px window. The digits keep the inherited dark text color, so they
  render as solid near-black numerals thickened by the 2px stroke.
- `.counter-1, .counter-2, .counter-3`: `position: relative; top: -15px` (optical centering
  inside the clip window).
- `.num1offset1 { position: relative; right: -30px }` and
  `.num1offset2 { position: relative; right: -15px }` — kerning fixes for the narrow "1" glyph.
- `.images-container`: absolutely fills the hero.
- `.images-container .img`: `position: absolute; top: 1.5rem; left: 1.5rem; width: 20%;
  aspect-ratio: 5/3; border-radius: 0.75rem; overflow: hidden` — all 15 stack in the same
  top-left spot.
- `.images-container .img.animate-out`: `top: unset; left: unset; bottom: 1.5rem;
  right: 1.5rem` — same size, anchored bottom-right. This class swap is what Flip animates.
- `nav`: `position: relative; width: 100vw; height: 5rem;
  padding: 1.5rem 1.5rem 1.5rem 7.5rem; display: flex; justify-content: space-between;
  align-items: center`. `.nav-items` is flex with `gap: 7.5rem`; `.links` flex with
  `gap: 0.5rem`. Its `.divider` is absolute at the bottom, full width, `height: 1px`,
  `transform-origin: left`, initial `transform: scaleX(0%)`.
- `.sidebar`: absolute, top-left, `width: 5rem; height: 100svh; padding-top: 1.5rem`, flex
  centered horizontally, items at the top. `.sidebar .logo`: `width: 2rem; aspect-ratio: 1;
  transform: scale(0)`. `.sidebar .divider`: absolute at its right edge, `width: 1px;
  height: 100svh; transform-origin: top; transform: scaleY(0%)`.
- All `.divider`s share `background-color: var(--stroke)`.
- `.header`: absolute, `top: 35%; left: 7.5rem; transform: translateY(-50%); width: 60%`.
- `.site-info`: absolute, `right: 1.5rem; top: 60%; transform: translateY(-50%); width: 20%`,
  flex column `gap: 1rem`. Its `.divider` is `width: 100%; height: 1px; transform-origin: left;
  scaleX(0%)`. `.site-info-copy` is a flex column with `gap: 0.25rem`.
- `.hero-footer`: absolute, `bottom: 1.5rem; left: 7.5rem`.
- SplitText mask styles: `.line { overflow: hidden }` and
  `.line span { position: relative; display: block; transform: translateY(125%);
  will-change: transform }` — every line starts pushed below its mask.

## GSAP effect (exhaustive)

Everything happens inside a single `DOMContentLoaded` listener. Order of setup, then one main
timeline plus three independent counter tweens.

### 1. Text splitting (setup, before any animation)

For **every** `h1, h2, p, a` on the page, run
`SplitText.create(element, { type: "lines", linesClass: "line" })`. Then, for each produced
`.line` element, replace its innerHTML with `<span>{its textContent}</span>` — i.e. manually
wrap each line's text in a `<span>`. Combined with the CSS above, every line of text is a
`<span>` at `translateY(125%)` inside an `overflow: hidden` `.line` mask.

### 2. Counter digit columns (setup)

Build three vertical digit strips (each child is `<div class="num">`):

- `.counter-1` (hundreds): two nums — `"0"`, then `"1"` with extra class `num1offset1`.
- `.counter-2` (tens): eleven nums for `i = 0..10` — text is `i`, except `i === 10` renders
  `"0"`; the `i === 1` num gets extra class `num1offset2`.
- `.counter-3` (units): thirty nums for `i = 0..29` with text `i % 10` (0–9 repeated three
  times), plus one final `"0"` num — 31 total.

So the resting state reads "000" and the final state (each column translated to its last num)
reads "100".

### 3. Counter roll tweens (independent `gsap.to` calls, fired immediately)

Helper `animateCounter(counter, duration, delay = 0)`:
- `numHeight = counter.querySelector(".num").clientHeight`
- `totalDistance = (numberOfNums - 1) * numHeight`
- `gsap.to(counter, { y: -totalDistance, duration, delay, ease: "power2.inOut" })`

Calls, in this order:
- `.counter-3` → duration **2.5**, no delay
- `.counter-2` → duration **3**, no delay
- `.counter-1` → duration **2**, delay **1.5**

All three columns land simultaneously at ~3.5s, reading 100.

### 4. Main timeline (`const tl = gsap.timeline()`)

Before the timeline: `gsap.set(".img", { scale: 0 })`.

1. `tl.to(".hero-bg", { scaleY: "100%", duration: 3, ease: "power2.inOut", delay: 0.25 })`
   — the darker panel wipes up from the bottom (transform-origin bottom).
2. `tl.to(".img", { scale: 1, duration: 1, stagger: 0.125, ease: "power3.out" }, "<")` — at
   the same time, the 15 stacked thumbnails pop in one after another (15 × 0.125 ≈ 1.75s of
   stagger + 1s tween).
3. `tl.to(".counter", { opacity: 0, duration: 0.3, ease: "power3.out", delay: 0.3,
   onStart: () => animateImages() })` — after the wipe finishes (plus 0.3s), the counter fades
   out and the Flip sequence (below) is kicked off via `onStart`. Note `animateImages()` runs
   as its own timeline, **not** nested in `tl`, so the following steps overlap it.
4. `tl.to(".sidebar .divider", { scaleY: "100%", duration: 1, ease: "power3.inOut",
   delay: 1.25 })` — vertical sidebar line draws downward.
5. `tl.to(["nav .divider", ".site-info .divider"], { scaleX: "100%", duration: 1,
   stagger: 0.5, ease: "power3.inOut" }, "<")` — horizontal lines draw from the left,
   0.5s apart.
6. `tl.to(".logo", { scale: 1, duration: 1, ease: "power4.inOut" }, "<")` — sidebar logo mark
   scales 0 → 1.
7. `tl.to([".logo-name a span", ".links a span, .links p span", ".cta a span"],
   { y: "0%", duration: 1, stagger: 0.1, ease: "power4.out", delay: 0.5 }, "<")` — nav text
   lines rise out of their masks (from the CSS `translateY(125%)` to `0%`).
8. `tl.to([".header span", ".site-info span", ".hero-footer span"],
   { y: "0%", duration: 1, stagger: 0.1, ease: "power4.out" }, "<")` — heading, side info and
   footer lines rise, starting together with step 7 (both positioned at `"<"`).

### 5. `animateImages()` — the Flip move (star of the show)

```
const images = document.querySelectorAll(".img");
images.forEach((img) => img.classList.remove("animate-out"));
const state = Flip.getState(images);
images.forEach((img) => img.classList.add("animate-out"));

const mainTimeline = gsap.timeline();
mainTimeline.add(
  Flip.from(state, { duration: 1, stagger: 0.1, ease: "power3.inOut" })
);
```

Then, for **each** image (index `i`), build a nested scale-punch timeline and add it to
`mainTimeline` at position `i * 0.1` (matching the Flip stagger so each punch rides its own
flight):

- `.to(img, { scale: 2.5, duration: 0.45, ease: "power3.in" }, 0.025)` — grows while leaving
  the corner,
- `.to(img, { scale: 1, duration: 0.45, ease: "power3.out" }, 0.5)` — shrinks back as it lands
  bottom-right.

Net effect: images peel off the top-left stack one every 0.1s, each swelling to 2.5× at the
midpoint of its 1s diagonal Flip journey and settling at 1× on the bottom-right stack.

## Assets / images

- **15 photographic thumbnails**, aspect ratio **5:3** (landscape), varied editorial/portfolio
  photography — they render as small rounded-corner cards (~20% viewport width). Cropping is
  fine (`object-fit: cover`).
- **1 small square logo mark** (1:1, ~32px render size), simple dark abstract glyph on
  transparent or matching background, used in the left sidebar.

Use neutral placeholder text/branding — the demo studio name is "Omno".

## Behavior notes

- Runs exactly once per page load; no user input required and no scroll interaction.
- Total sequence ≈ 5.5–6s from load to fully revealed layout.
- Responsive: below `1000px` wide — `h1` drops to `2.5rem` (letter-spacing `-0.05rem`), `h2`
  to `1.5rem`, `nav .links` is hidden, `.img` widens to `30%`, `.header` moves to `top: 25%`
  with `width: calc(100% - 12.5rem)`, and `.site-info` spans `calc(100% - 12.5rem)` anchored
  `left: 7.5rem` instead of right.
- The counter digits rely on `clientHeight` of a rendered `.num`, so build the columns before
  starting the tweens (same tick is fine — the elements just need to be in the DOM).
- No reduced-motion handling is required; keep the hero `overflow: hidden` so flipped images
  never spill.
