# Physics Gravity Landing Page (Matter.js drop + GSAP overlay)

## Goal

Build a full-viewport, single-screen hero where **twelve small image tiles** (90×60px each, thin black border) are scattered across a flat tan panel. A `[ Drop / Raise ]` button in the top-right toggles real physics: on **Drop**, Matter.js gravity switches on and all twelve tiles **fall, tumble with random spin, bounce and pile up on an invisible floor** at the bottom. On **Raise**, gravity switches off and a manual eased `requestAnimationFrame` lerp **glides every tile back to its exact starting position and 0° angle**. In sync with the drop, GSAP animates the tan overlay's `clip-path` (collapsing it into a thin band at the bottom), grows and re-positions the giant `MOTIONPROMPTS` headline, flips the toggle button's color, and reveals four columns of footer nav links from behind a clip mask. Raising reverses all of it. The whole thing is one screen — no scroll.

## Tech

Vanilla HTML/CSS/JS with ES module imports (Vite-style npm imports). Use:

- `gsap` (npm) — for all the overlay/headline/text/button tweens. **No GSAP plugins** (no ScrollTrigger, no SplitText — text splitting is done by hand).
- `matter-js` (npm) — the physics engine. Destructure `const { Engine, Runner, World, Bodies, Body, Events } = Matter;`.

```js
import gsap from "gsap";
import Matter from "matter-js";
```

No smooth-scroll library, no canvas, no WebGL. Matter.js runs **headless** (no `Render`) and its bodies drive the DOM tiles via inline `style.top/left/transform` on every `afterUpdate` tick. All code runs at module top level (no `DOMContentLoaded` wrapper needed since the module is loaded at end of `<body>`).

## Layout / HTML

```
<div class="container">              <!-- z-index:2, holds the 12 tiles -->
  <button id="toggle-btn">[ Drop / Raise ]</button>
  <div class="item item-1"><img src="…" alt="" /></div>
  … item-2 … through … item-12 …    <!-- 12 total, each wraps one <img> -->
</div>

<div class="overlay">                 <!-- z-index:0, the tan full-screen panel -->
  <h1>Motionprompts</h1>
</div>

<div class="content">                 <!-- footer nav, 4 flex columns -->
  <div class="col"> 5× <div class="line"><p>…</p></div> </div>
  … 4 columns total …
</div>
```

- The 12 tiles are `.item .item-1` … `.item .item-12`; each contains exactly one `<img>`.
- The toggle button text is literally `[ Drop / Raise ]`.
- The headline text is `Motionprompts` (uppercased via CSS).
- The four `.col` columns each hold five `.line` rows; each `.line` wraps one `<p>`. Column 1 line texts: `About Us / Our Team / Our Mission / Careers / Contact`. Column 2: `Services / Web Development / Mobile Apps / UI/UX Design / SEO Optimization`. Column 3: `Projects / E-commerce / Portfolio / Blog / Landing Pages`. Column 4: `Resources / Tutorials / Documentation / Community / Support`. (Any neutral link labels work; the first line of each column is a dimmed "header".)

## Styling

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

- `html, body`: `width:100vw; height:100vh; background:#000; overflow:hidden;`. **Body font — subtle but load-bearing, read carefully.** The original declares `font-family: "Basier Square Mono";` with **no generic fallback**, and that licensed/trial font is *not* bundled, so every browser falls back to its **default serif (Times New Roman)**. The nav/footer links and the toggle button therefore render in a **proportional serif**, NOT a monospace. Reproduce that exactly: `font-family: "Basier Square Mono", "Times New Roman", Times, serif;`. Do **not** substitute a monospace (`ui-monospace`, `SFMono-Regular`, etc.) and do **not** self-host or web-load a mono font — either would diverge from the captured reference.
- `img { width:100%; height:100%; object-fit:cover; }`.
- `.container { position:absolute; width:100%; height:100%; z-index:2; }` — the tile layer sits on top.
- `#toggle-btn`: `position:absolute; top:2em; right:2em; background:none; border:none; outline:none; text-transform:uppercase; font-size:12px; padding:0.5em 1em; cursor:pointer; color:#000; mix-blend-mode:difference; z-index:2;` (inherits the same serif body font — `"Basier Square Mono", "Times New Roman", Times, serif` — so it too is a proportional serif, not a monospace). The `mix-blend-mode:difference` makes it invert against whatever is behind it.
- `.item`: `position:absolute; width:90px; height:60px; border:2px solid #000;` (3:2 landscape tiles). Their scattered **initial** positions (top/left as % of the viewport, this is the "floating grid" pose):
  - item-1 `top:50% left:5%` · item-2 `15%/10%` · item-3 `25%/15%` · item-4 `5%/37.5%` · item-5 `35%/40%` · item-6 `30%/52.5%` · item-7 `40%/50%` · item-8 `20%/60%` · item-9 `60%/65%` · item-10 `27.5%/75%` · item-11 `37.5%/85%` · item-12 `65%/82.5%`.
- `.overlay`: `position:absolute; top:0; left:0; width:100%; height:100%; padding:1em; background:#aaaaa0; z-index:0;` and **initial** `clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);` (a full rectangle — the tan panel covers the whole screen). `#aaaaa0` is a muted sage/tan.
- `.overlay h1`: `position:absolute; bottom:0; left:0; padding:1em; text-transform:uppercase; line-height:100%;`. **Headline font — the single most visible detail, get it right.** The original declares `font-family: "Segment A Key Trial";` with **no generic fallback**; that trial font is *not* bundled, so the browser falls back to its **default serif (Times New Roman)**. Rendered huge, plain Times reads as a **high-contrast Didone / modern-serif display**: thin hairline serifs, strong thick↔thin stroke contrast — that is the intended look, and it is what the reference actually shows. Reproduce it with a serif chain that reliably lands on Times: `font-family: "Segment A Key Trial", "Times New Roman", Times, serif;`. It is **NOT** a heavy sans / grotesque: do **not** use `"Arial Black"` or any bold sans-serif, and do **not** web-load a display serif (Bodoni Moda, Playfair Display, etc.) — those would render a *different* serif than the captured reference. Letters should look elegant and thin-stroked, not fat and blocky.
- `.overlay h1 span`: `font-size:10vw;` — the headline is split into per-character `<span>`s in JS and **each span carries the font-size** (so animating span font-size scales every letter).
- `.content`: `position:relative; width:100%; display:flex;` — the four columns sit in normal flow at the top of the page (painting above the `z-index:0` overlay, below the `z-index:2` tiles).
- `.col`: `flex:1; padding:2em; gap:2em;`. `.col .line:nth-child(1) { margin-bottom:1em; opacity:0.5; }` (dim column header).
- `.line`: `position:relative; width:100%; height:24px; opacity:0.75; clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);` — a 24px-tall **clip window**.
- `.line p`: `position:absolute; text-transform:uppercase; font-size:12px; color:#fff; transform:translateY(30px);` — **starts pushed 30px down**, i.e. below the 24px clip window, so the link text is hidden until GSAP lifts it to `y:0`.

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

### 1. Split the headline into character spans (manual, no SplitText)

Run a helper on the `h1`: take its `innerText`, split into characters, wrap each in `<span>…</span>` (replace a space with `&nbsp;&nbsp;`), and set `element.innerHTML` to the joined result. This turns `MOTIONPROMPTS` into 13 individual `<span>`s so GSAP can stagger `font-size` per letter.

### 2. Matter.js setup (gravity OFF, bodies static)

```js
const engine = Engine.create({ gravity: { x: 0, y: 0 } });   // gravity starts OFF
const runner = Runner.create();
Runner.run(runner, engine);                                  // runner runs immediately
```

- Grab all `.item` elements. Snapshot each tile's **initial** grid position: `initialPositions[i] = { x: item.offsetLeft, y: item.offsetTop, angle: 0 }`.
- For each tile create a rectangle body centered on the tile:
  ```js
  Bodies.rectangle(
    initialPositions[i].x + item.offsetWidth / 2,
    initialPositions[i].y + item.offsetHeight / 2,
    item.offsetWidth,          // 90
    item.offsetHeight,         // 60
    { restitution: 0.75, friction: 0.5, frictionAir: 0.0175, isStatic: true }
  );
  ```
  Add each to the world, keep them in a `bodies[]` array (index-aligned with `items`). `restitution:0.75` = bouncy; `frictionAir:0.0175` = mild air drag; all start `isStatic:true` so nothing moves until Drop.
- Add one **static floor** just below the viewport: `Bodies.rectangle(window.innerWidth/2, window.innerHeight + 5, window.innerWidth, 20, { isStatic:true })`.

### 3. State & constants

```js
let gravityEnabled = false;   // false = raised/floating, true = dropped
let isAnimating   = false;    // click lock during a transition
const duration    = 0.75;     // seconds, for the manual raise-back lerp
const easeOutQuad = (t) => t * (2 - t);
```

### 4. Toggle button click handler (`#toggle-btn`)

Guard at the top: `if (isAnimating) return; isAnimating = true;`. Then branch on the **current** `gravityEnabled`:

**Drop branch (`!gravityEnabled`):**
- `engine.world.gravity.y = 1;` (turn gravity on).
- For every body: `Body.setStatic(body, false)` then `Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.25)` — wake it up and give it a small random spin (±0.125 rad/s). Gravity then pulls all twelve tiles down; they tumble, bounce (restitution 0.75) and pile on the floor.
- Set `gravityEnabled = true`.

**Raise branch (`gravityEnabled`):** a manual eased lerp back to the grid (no GSAP here):
- `engine.world.gravity.y = 0;`
- For each body `i`: `Body.setStatic(body, true)`. Capture `startPos = {x: body.position.x, y: body.position.y}`, `startAngle = body.angle`. Target `endPos = { x: initialPositions[i].x + items[i].offsetWidth/2, y: initialPositions[i].y + items[i].offsetHeight/2 }`, `endAngle = 0`. Record `startTime = performance.now()`.
- Drive a `requestAnimationFrame` loop `animateBack(currentTime)`:
  - `elapsed = (currentTime - startTime) / 1000; t = Math.min(elapsed / duration, 1); easedT = easeOutQuad(t);`
  - `x = startPos.x + easedT*(endPos.x - startPos.x)` (same for `y` and `angle` toward `endAngle`).
  - Apply inside a **`setTimeout(() => { Body.setPosition(body, {x, y}); Body.setAngle(body, angle); }, 750)`** — note the original defers each frame's write by 750 ms, so the glide-back visibly starts ~0.75 s after the click and eases into place (easeOutQuad, ~0.75 s span). Keep this 750 ms delay to match timing.
  - If `t < 1`, request the next frame.
- Set `gravityEnabled = false`.

**Both branches** then call `toggleClipPath()` (below) and finally `setTimeout(() => { isAnimating = false; }, 2000)` — a 2 s lock so you can't spam the button mid-transition.

### 5. `toggleClipPath()` — the GSAP overlay/headline/text/button tweens

`gravityEnabled` has **already been flipped** by the time this runs, so `gravityEnabled === true` means "we just dropped", `false` means "we just raised". Fire these five `gsap.to` calls (all run simultaneously):

- **Overlay clip-path** — `gsap.to(overlay, { clipPath: <A/B>, duration: 1.5, ease: "power3.inOut" })`:
  - dropped → `"polygon(5% 60%, 95% 60%, 95% 100%, 5% 100%)"` (collapse the tan panel into a short band across the lower area, 5–95% wide, 60→100% tall).
  - raised → `"polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"` (full-screen rectangle).
- **Button color** — `gsap.to("#toggle-btn", { color: <#fff / #000>, delay: 0.5, duration: 1 })`: dropped → `#fff`, raised → `#000`.
- **Headline position** — `gsap.to(".overlay h1", { left: <"32.5%" / "0%">, duration: 1, ease: "power4.inOut" })`: dropped slides the headline right to `32.5%`, raised back to `0%`.
- **Headline size** — `gsap.to(".overlay h1 span", { fontSize: <"20vw" / "10vw">, duration: 1, ease: "power4.inOut", stagger: <−0.035 / 0.035> })`: dropped grows every letter to `20vw` with a **negative** stagger `-0.035` (last letter first); raised shrinks to `10vw` with `+0.035` (first letter first).
- **Footer links** — for **each** `.col` separately, `gsap.to(col.querySelectorAll(".line p"), { y: <0 / 30>, delay: <0.75 / 0>, duration: 1, ease: "power3.out", stagger: <0.1 / −0.1> })`:
  - dropped → `y:0` (lift each link up into its clip window, revealing it), `delay:0.75`, `stagger:0.1` (top-to-bottom cascade).
  - raised → `y:30` (drop back out of the clip, hidden), `delay:0`, `stagger:-0.1` (reverse cascade).
  - Because it's applied per-column, all four columns animate at once, each staggering its own five rows.

Summary of the two visual states:
- **Raised (initial):** full tan panel; twelve tiles floating in their scattered grid; `MOTIONPROMPTS` at 10vw pinned bottom-left in **high-contrast Times serif**; footer links hidden; toggle button black (inverting via blend against tan).
- **Dropped:** tiles have fallen and piled at the bottom; tan overlay collapsed to a thin lower band; `MOTIONPROMPTS` grown to 20vw (same thin-serif Times face) and shifted to `left:32.5%`; four columns of white serif footer links revealed; toggle button white.

**Typography recap (do not skip):** both the giant headline and all the small nav/footer/button text render in the browser's **default serif (Times New Roman)** — because the original references two uninstalled trial fonts (`"Segment A Key Trial"`, `"Basier Square Mono"`) with no generic fallback. Match that: serif everywhere, headline = thin high-contrast display serif, body = proportional serif. Never a bold sans (Arial Black) for the headline, never a monospace for the body.

### 6. Physics → DOM sync (`afterUpdate`)

Subscribe once: `Events.on(engine, "afterUpdate", …)`. On **every** engine tick, for each body/tile pair write the tile's inline style from the body:

```js
item.style.top       = `${body.position.y - item.offsetHeight / 2}px`;
item.style.left      = `${body.position.x - item.offsetWidth  / 2}px`;
item.style.transform = `rotate(${body.angle}rad)`;
```

This runs continuously (the runner is always on), so the tiles follow their bodies both while falling and while being lerped back. In the raised state the bodies are static at the grid centers, so the tiles hold their scattered positions.

## Assets / images

**12 image tiles**, each rendered `object-fit:cover` inside a **90×60px (3:2 landscape)** black-bordered frame — so any source aspect is fine, it will be center-cropped to 3:2. Use a cohesive set of **moody, high-fashion / editorial art-direction** stills so the scattered grid reads like a gallery wall. Variety within the set (one idea per tile, no repeats): a dark still-life of a bottle on draped fabric; an editorial figure crouching on a saturated colored backdrop; a hooded/veiled portrait on black; a blue light-installation corridor with silhouetted figures; a grainy high-contrast B&W portrait in sunglasses; a dim runway/fashion-show walk under a spotlight; a caped figure with a wide-brim hat in a concrete space; a warm-lit gallery interior with a silhouetted viewer; a studio fashion portrait on a blue gradient; a minimalist monochrome desert with a lone walker; an overhead of a figure on an ornate golden staircase; a studio portrait in a black turtleneck and beret on pale grey. Provide 12 files in order; if fewer are available, repeat in order. **No client logos or real brand names** — the demo brand is the fictional word `MOTIONPROMPTS`.

## Behavior notes

- **Single screen, no scroll.** `body { overflow:hidden }`; everything is absolute/fixed to the viewport. The floor lives 5px below the bottom edge.
- **State machine:** the button strictly alternates Drop → Raise → Drop; the 2 s `isAnimating` lock prevents overlapping transitions.
- **The Raise is not a physics simulation** — gravity is turned off and bodies are set static, then a hand-rolled easeOutQuad rAF lerp (with the 750 ms deferred write) glides every body from its tumbled pose back to the exact captured grid center and 0 rad. Keep both the lerp and the `setTimeout(…, 750)` to reproduce the timing.
- No reduced-motion handling and no resize recomputation in the original (floor/bodies are sized once on load). The effect is desktop-oriented but works down to smaller widths (tiles just have less room to fall).
- Performance is on the heavier side (a live Matter.js runner plus per-tick DOM writes for 12 elements), but it's only active while the page is open.
