Playable Objects — Physics Pill Footer
Goal
Build a two-screen page whose second screen is a dark footer where 12 white "pill" tags rain down from above the viewport and pile up under real 2D physics (Matter.js). A GSAP ScrollTrigger with once: true boots the physics engine the first time the footer scrolls into view; every pill is a Matter.js rigid body synced to a DOM element via a requestAnimationFrame loop, and a Matter MouseConstraint lets the user grab, drag and fling the pills in real time. Smooth scrolling via Lenis. The centered footer headline reads "Because why list when you can play?".
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, lenis for smooth scroll, and matter-js for the physics simulation (import Matter from "matter-js"). Register the plugin with gsap.registerPlugin(ScrollTrigger). No canvas rendering — Matter runs headless and you drive the DOM elements yourself.
Layout / HTML
Two stacked full-viewport sections:
<section class="hero">
<h1>Scroll down to break the laws of web design</h1>
</section>
<section class="footer">
<div class="object-container">
<div class="object"><p>Motionprompts</p></div>
<div class="object"><p>HTML</p></div>
<div class="object"><p>CSS</p></div>
<div class="object"><p>JavaScript</p></div>
<div class="object"><p>GSAP</p></div>
<div class="object"><p>ScrollTrigger</p></div>
<div class="object"><p>Lenis</p></div>
<div class="object"><p>React</p></div>
<div class="object"><p>Next.js</p></div>
<div class="object"><p>WebGL</p></div>
<div class="object"><p>Three.js</p></div>
<div class="object"><p>Creative Dev</p></div>
</div>
<div class="footer-content">
<h1>Because why list when you can play?</h1>
</div>
</section>
<script type="module" src="./script.js"></script>
Key classes the JS depends on: .object-container (the physics world bounds) and .object (one per rigid body). There are 12 pills with those exact tech-tag labels.
Styling
Font — Google Fonts DM Sans (import the full variable range, weights 100..1000). body { font-family: "DM Sans", sans-serif; }.
Reset / global
* { margin: 0; padding: 0; box-sizing: border-box; }h1 { font-size: 4rem; font-weight: 500; letter-spacing: -0.04rem; line-height: 1.2; user-select: none; }section { position: relative; width: 100vw; height: 100svh; padding: 2rem; overflow: hidden; }.hero h1, .footer h1 { width: 45%; text-align: center; }
Hero — display: flex; justify-content: center; align-items: center; background: #fff; color: #0f0f0f;.
Footer — background-color: #0f0f0f; color: #fff; (near-black on white pills is the whole palette).
.footer-content — position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 2rem; display: flex; justify-content: center; align-items: center; pointer-events: none; with .footer-content * { pointer-events: auto; }. It overlays the physics layer but lets pointer events pass through to the pills.
.object-container — position: absolute; top: 0; left: 0; width: 100%; height: 100%;. Fills the footer; this rect defines the physics walls.
.object — position: absolute; width: max-content; font-size: 2rem; font-weight: 500; background-color: #fff; color: #0f0f0f; padding: 1rem 2rem; border-radius: 50px; cursor: grab; user-select: none; pointer-events: auto; z-index: 2; and .object:active { cursor: grabbing; }. The pills start with no left/top set in CSS — JS positions them every frame.
Responsive @media (max-width: 1000px): h1 { font-size: 2rem; }, .hero h1, .footer h1 { width: 100%; }, .object { font-size: 1rem; }.
GSAP + Matter.js effect (be exact)
Wrap everything in DOMContentLoaded.
1. Lenis smooth scroll wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. Config object (exact values)
const config = {
gravity: { x: 0, y: 1 },
restitution: 0.5,
friction: 0.15,
frictionAir: 0.02,
density: 0.002,
wallThickness: 200,
mouseStiffness: 0.6,
};
Module-scope state: let engine, runner, mouseConstraint, bodies = [], topWall = null; plus a clamp(val, min, max) helper.
3. ScrollTrigger boot (the GSAP part)
Loop over every section; for the one containing .object-container, create:
ScrollTrigger.create({
trigger: section,
start: "top bottom", // fires as soon as the footer's top touches the viewport bottom
once: true, // physics initializes exactly once, ever
onEnter: () => {
const container = section.querySelector(".object-container");
if (container && !engine) initPhysics(container);
},
});
No scrub, no pin, no tween — the ScrollTrigger is purely a lazy-init trigger. (Keep an animateOnScroll = true flag; if false, fall back to initializing on window load instead.)
4. initPhysics(container)
engine = Matter.Engine.create();engine.gravity = config.gravity; solver quality cranked up:engine.constraintIterations = 10; engine.positionIterations = 20; engine.velocityIterations = 16; engine.timing.timeScale = 1;.- Measure
containerRect = container.getBoundingClientRect(). - Three static walls (200px thick rectangles, all
isStatic: true), centered just outside the container so their inner faces sit exactly on the container edges — note there is no top wall initially so bodies can fall in: - floor: center
(width/2, height + 100), size(width + 400) × 200 - left wall: center
(-100, height/2), size200 × (height + 400) - right wall: center
(width + 100, height/2), size200 × (height + 400) - One rectangle body per
.objectelement. For each (with its measuredgetBoundingClientRect()size): startX = Math.random() * (containerRect.width - objRect.width) + objRect.width / 2(random, fully inside horizontally)startY = -500 - index * 200(staggered spawn heights far above the container → they fall in one after another)startRotation = (Math.random() - 0.5) * Math.PI(random tilt ±90°), applied viaMatter.Body.setAngle- body options:
restitution: 0.5, friction: 0.15, frictionAir: 0.02, density: 0.002 - push
{ body, element, width, height }intobodiesand add the body to the world. - Seal the ceiling after 3 seconds:
setTimeout(..., 3000)adds a fourth static wall at center(width/2, -100), size(width + 400) × 200, so once everything has fallen in, flung pills can't escape upward.
5. Mouse dragging (MouseConstraint)
const mouse = Matter.Mouse.create(container);then remove Matter's wheel hijacking so page scroll keeps working:
mouse.element.removeEventListener("mousewheel", mouse.mousewheel); and same for "DOMMouseScroll".
mouseConstraint = Matter.MouseConstraint.create(engine, { mouse, constraint: { stiffness: 0.6, render: { visible: false } } });and disable the context menu:mouseConstraint.mouse.element.oncontextmenu = () => false;.- startdrag event: remember the body's
inertia, thenMatter.Body.setInertia(body, Infinity)(no spin while held),setVelocity({x:0,y:0}),setAngularVelocity(0). - enddrag event: restore the original inertia (
|| 1) and clear the dragging refs. - engine
beforeUpdate: while a body is dragged, clamp its position so it can't be pulled outside the container (xbetweenwidth/2andcontainerWidth - width/2, same idea fory), and clamp its velocity components to ±20 — this is what makes "flinging" fast but bounded. - Release the constraint (set
mouseConstraint.constraint.bodyB = null; ...pointB = null;) on both containermouseleaveand documentmouseup, so a pill is never stuck to a pointer that left. - Add the mouseConstraint to the world; start the sim with
runner = Matter.Runner.create(); Matter.Runner.run(runner, engine);.
6. DOM sync loop
A self-scheduling requestAnimationFrame loop maps each physics body onto its element:
const x = clamp(body.position.x - width / 2, 0, containerRect.width - width);
const y = clamp(body.position.y - height / 2, -height * 3, containerRect.height - height);
element.style.left = x + "px";
element.style.top = y + "px";
element.style.transform = `rotate(${body.angle}rad)`;
Positions are top-left based (left/top), rotation via transform: rotate(...rad). The y clamp floor of -height * 3 lets pills be visible slightly above the container while falling in, but the render never places them outside the box horizontally.
Assets / images
None. The component is pure typography and CSS — no raster images, no SVG, no 3D models.
Behavior notes
- The pills only ever spawn once (
once: true+!engineguard); scrolling away and back does not re-drop them. - Because the spawn heights are staggered (
-500 - index * 200) the 12 pills enter the frame sequentially over ~2–3 seconds, bouncing (restitution 0.5) and settling into a pile. - Wheel/touch scrolling stays functional over the footer because Matter's mouse wheel listeners are explicitly removed; only click-drag is captured.
- Physics + rAF DOM writes are heavy: desktop-oriented, no reduced-motion guard in the original.
- Container bounds are measured once at init; the sim is not rebuilt on resize.