Interactive Divs with Physics — floating grayscale cards that scatter from the cursor
Goal
Build a full-screen, black, zero-gravity playground where twelve grayscale polaroid-style image cards drift over the whole viewport. Each card is a real HTML <div> whose position and rotation are driven, every frame, by a Matter.js rigid body living in a gravity-free physics world. A big centered word sits behind them. The star effect: moving the mouse near a card fires a random impulse into it, so nearby cards shoot away, slowly coast to a stop under air friction, gently bounce off the invisible viewport walls, and keep floating. It reads like a slow-motion swarm of photo prints you can bat around with the cursor.
Tech
Vanilla HTML/CSS/JS. Two libraries with two different loading mechanisms (match this exactly — the sketch depends on p5 running in "global mode"):
- p5.js v1.4.0 as a classic CDN
<script>in<head>(NOT an npm import). This runs p5 in global mode: p5 auto-initializes on window load and attaches globals likecreateCanvas,random,dist,background,width,height,mouseX,mouseY, and calls your globalsetup()/draw()/mouseMoved()functions.
``html <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script> ``
- matter-js as an npm ESM import in your module script:
``js import Matter from "matter-js"; const { Engine, World, Bodies, Body } = Matter; ``
No GSAP, no ScrollTrigger, no Lenis, no framework. The page does not scroll.
Global-mode bridge (critical): because your code is an ES module, setup/draw/mouseMoved are module-scoped and p5 can't see them. At the very bottom of the module, expose them on window:
window.setup = setup;
window.draw = draw;
window.mouseMoved = mouseMoved;
Layout / HTML
Minimal. p5 injects its own <canvas> and the JS creates the card divs at runtime.
<body>
<div class="header"><h1>Motionprompts</h1></div>
<script type="module" src="./script.js"></script>
</body>
- One
.headerwith an<h1>(fictional brand textMotionprompts), absolutely centered — it sits *behind* the floating cards (the p5 canvas and the.itemdivs are appended to<body>after it and paint on top). - No card markup in HTML: the twelve
<div class="item">elements (each containing one<img>) are created in JS and appended todocument.body.
Styling
Reset and full-bleed stage:
* { margin:0; padding:0; box-sizing:border-box; }html, body { width:100vw; height:100vh; overflow:hidden; background:#000; }— the page is exactly one viewport, no scroll, pure black behind everything.
Header (centered background word):
.header { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }.header h1 { font-family:"Circular Std", sans-serif; font-size:12vw; font-weight:500; letter-spacing:-0.05em; line-height:175%; color:#fff; text-align:center; }— a very large, tight, geometric-sans word in white. Any clean geometric sans works as fallback.
The cards (.item — a white polaroid frame):
.item { position:absolute; width:200px; height:225px; padding:0.5em 0.5em 4em 0.5em; overflow:hidden; background:#fff; }— a 200×225px white card with an asymmetric pad (thin on top/sides,4emthick at the bottom) so the photo sits in the upper area and leaves a polaroid-style white chin..item img { width:100%; height:100%; object-fit:cover; filter:grayscale(100%); }— the photo fills the padded content box, cropped, and rendered fully grayscale.
JS sets each card's inline left, top, and transform: rotate(<rad>) every frame (see below).
Physics + render effect (be exhaustive — this is the whole component)
Engine & world
let engine = Engine.create();
engine.world.gravity.y = 0; // ZERO gravity — cards float, they never fall
Keep module-level state: engine, items = [], lastMouseX = -1, lastMouseY = -1.
setup() (runs once)
createCanvas(window.innerWidth, window.innerHeight);— a p5 canvas the full size of the window.engine = Engine.create();thenengine.world.gravity.y = 0;.addBoundaries();(below).- Spawn 12 cards in a loop
for (let i = 0; i < 12; i++): x = random(100, width - 100),y = random(100, height - 100)— random start inside a 100px inset.items.push(new Item(x, y, "img" + (i + 1) + ".jpg"));— image sourcesimg1.jpg … img12.jpg.
addBoundaries() — four invisible static walls
Four static rectangles of thickness = 50, positioned so their inner edge lines up with the viewport edges (bodies sit just *outside* the visible area). Add all four with World.add(engine.world, [...]):
- Top:
Bodies.rectangle(width/2, -thickness/2, width, thickness, { isStatic:true }) - Bottom:
Bodies.rectangle(width/2, height + thickness/2, width, thickness, { isStatic:true }) - Left:
Bodies.rectangle(-thickness/2, height/2, thickness, height, { isStatic:true }) - Right:
Bodies.rectangle(width + thickness/2, height/2, thickness, height, { isStatic:true })
These keep the cards contained; cards bounce off them (see restitution).
Item class
Constructor (x, y, imagePath):
- Physics body options:
{ frictionAir: 0.075, restitution: 0.25, density: 0.002, angle: Math.random() * Math.PI * 2 }. frictionAir: 0.075— heavy air drag, so impulses decay to a stop within ~1s (the slow, floaty coast).restitution: 0.25— soft, low-energy bounces off walls and each other.density: 0.002— light bodies (mass = density × area = 0.002 × 100 × 200 = 40) so the impulse below produces a big, visible kick.angle— each card starts at a random rotation (0–2π).- Body:
this.body = Bodies.rectangle(x, y, 100, 200, options);— a 100×200 rectangle body (note: smaller than the 200×225 visual card; this is intentional/original).World.add(engine.world, this.body);. - DOM: create
<div class="item">, position it (see offset math), create an<img>withsrc = imagePath, append the img to the div, append the div todocument.body.
Position offset (used in constructor and every frame in update()):
this.div.style.left = (this.body.position.x - 50) + "px";
this.div.style.top = (this.body.position.y - 100) + "px";
The div's top-left is offset −50px x / −100px y from the body center (half of the 100×200 *body* size, not the card size — keep these exact numbers).
update() (called every frame):
this.div.style.left = (this.body.position.x - 50) + "px";
this.div.style.top = (this.body.position.y - 100) + "px";
this.div.style.transform = "rotate(" + this.body.angle + "rad)";
So each card's screen position and rotation mirror its physics body exactly, in radians.
draw() — the p5 render loop (~60fps)
function draw() {
background("black"); // repaint canvas black each frame
Engine.update(engine); // step the physics simulation one tick
items.forEach((item) => item.update()); // sync every card div to its body
}
The canvas itself only ever shows solid black; all the visible motion is the DOM .item divs being repositioned on top of it. There is no noLoop() — the loop runs continuously so bodies keep integrating (drifting, decaying, settling) even without input.
mouseMoved() — the interaction (the star moment)
Throttle by how far the mouse has moved, then push nearby cards:
function mouseMoved() {
if (dist(mouseX, mouseY, lastMouseX, lastMouseY) > 10) {
lastMouseX = mouseX;
items.forEach((item) => {
if (dist(mouseX, mouseY, item.body.position.x, item.body.position.y) < 150) {
const forceMagnitude = 3;
Body.applyForce(
item.body,
{ x: item.body.position.x, y: item.body.position.y }, // apply at the body's own center → pure linear push, no torque
{ x: random(-forceMagnitude, forceMagnitude),
y: random(-forceMagnitude, forceMagnitude) } // random 2D impulse in [-3, 3] on each axis
);
}
});
}
}
Behavior to reproduce:
- Only reacts once the cursor has traveled >10px since the last sampled point (
lastMouseX/lastMouseYseeded at-1, so it fires as soon as the mouse enters the page). - For every card whose body center is within 150px of the cursor, apply a random force vector, each component uniformly in
[-3, 3], at the card's own center of mass (so it's a straight shove, no spin from the force — any rotation you see is incidental collision response). - With
frictionAir 0.075the shoved cards fan out fast, then glide to rest;restitution 0.25gives them a gentle bounce if they reach a wall or clip a neighbor.
Assets / images
12 editorial photographs, img1.jpg … img12.jpg, one per card. Each is displayed cropped (object-fit:cover) into the polaroid's ~184×153px content window and forced to grayscale by CSS, so any source palette is fine. Aim for a cohesive, moody, cinematic/editorial set — e.g. atmospheric portraits, sci-fi/astronaut scenes, minimalist product shots, monochrome cityscapes — anything with strong tonal contrast reads well once desaturated. No logos or real brand marks. (If fewer than 12 images are available, repeat them in order.)
Behavior notes
- Desktop / pointer-driven, page-level component. The whole viewport is the stage; nothing scrolls (
overflow:hidden, page is exactly100vw × 100vh). Not mobile-safe (relies onmousemove). - Continuous simulation, no autoplay animation: cards start at random positions and rotations and simply sit still (zero gravity, no initial velocity) until the cursor disturbs them; then they scatter and re-settle. Cards can overlap and gently jostle via collisions.
- Canvas is sized to the window at load; there is no resize handler in the original (bodies/walls keep their initial dimensions if the window is resized).
- No reduced-motion handling and no console errors expected. Heavy-ish (12 DOM nodes synced to a 60fps physics loop) but smooth on desktop.