All components

Playable Objects

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A dark footer where tech-tag pills drop in from above and pile up under Matter.js physics (gravity, restitution, friction, staggered spawn heights). A ScrollTrigger with once:true fires the physics engine the first time the footer scrolls into view, Lenis drives smooth scrolling, and a Matter MouseConstraint lets you grab and fling each pill in real time.

How it's built

Categoryfooter
Techgsap, lenis, matter-js
GSAP pluginsScrollTrigger
Complexitysection
Performance costheavy
Mobile-safedesktop-first
Scrollhijacks scrolling

physics matter-js drag gravity scrolltrigger lenis interactive playful footer

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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; }

Herodisplay: flex; justify-content: center; align-items: center; background: #fff; color: #0f0f0f;.

Footerbackground-color: #0f0f0f; color: #fff; (near-black on white pills is the whole palette).

.footer-contentposition: 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-containerposition: absolute; top: 0; left: 0; width: 100%; height: 100%;. Fills the footer; this rect defines the physics walls.

.objectposition: 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), size 200 × (height + 400)
  • right wall: center (width + 100, height/2), size 200 × (height + 400)
  • One rectangle body per .object element. For each (with its measured getBoundingClientRect() 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 via Matter.Body.setAngle
  • body options: restitution: 0.5, friction: 0.15, frictionAir: 0.02, density: 0.002
  • push { body, element, width, height } into bodies and 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, then Matter.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 (x between width/2 and containerWidth - width/2, same idea for y), 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 container mouseleave and document mouseup, 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 + !engine guard); 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.