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. Destructureconst { Engine, Runner, World, Bodies, Body, Events } = Matter;.
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
.colcolumns each hold five.linerows; each.linewraps 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 declaresfont-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). Themix-blend-mode:differencemakes 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-215%/10%· item-325%/15%· item-45%/37.5%· item-535%/40%· item-630%/52.5%· item-740%/50%· item-820%/60%· item-960%/65%· item-1027.5%/75%· item-1137.5%/85%· item-1265%/82.5%. .overlay:position:absolute; top:0; left:0; width:100%; height:100%; padding:1em; background:#aaaaa0; z-index:0;and initialclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);(a full rectangle — the tan panel covers the whole screen).#aaaaa0is 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 declaresfont-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 thez-index:0overlay, below thez-index:2tiles)..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 toy: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 ), 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)
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
.itemelements. 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
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)thenBody.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). CapturestartPos = {x: body.position.x, y: body.position.y},startAngle = body.angle. TargetendPos = { x: initialPositions[i].x + items[i].offsetWidth/2, y: initialPositions[i].y + items[i].offsetHeight/2 },endAngle = 0. RecordstartTime = performance.now(). - Drive a
requestAnimationFrameloopanimateBack(currentTime): elapsed = (currentTime - startTime) / 1000; t = Math.min(elapsed / duration, 1); easedT = easeOutQuad(t);x = startPos.x + easedT*(endPos.x - startPos.x)(same foryandangletowardendAngle).- 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 to32.5%, raised back to0%. - Headline size —
gsap.to(".overlay h1 span", { fontSize: <"20vw" / "10vw">, duration: 1, ease: "power4.inOut", stagger: <−0.035 / 0.035> }): dropped grows every letter to20vwwith a negative stagger-0.035(last letter first); raised shrinks to10vwwith+0.035(first letter first). - Footer links — for each
.colseparately,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;
MOTIONPROMPTSat 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;
MOTIONPROMPTSgrown to 20vw (same thin-serif Times face) and shifted toleft: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:
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
isAnimatinglock 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.