Fluid Particle Simulation Hero (falling shapes that pile up like a liquid)
Goal
Build a full-screen contact/CTA hero on a saturated electric-blue background, with a centered white headline floating over it, and — painted across the whole viewport behind the text — a p5.js particle physics simulation: ~250 small white shapes (triangles, squares, circles) that spawn in a loose grid near the top, fall under gravity, collide, and pile up at the bottom with soft, fluid-like collision resolution (a spatial-grid neighbor solver that pushes overlapping particles apart and blends their velocities so the heap behaves like a settling liquid rather than rigid bodies). Pressing and dragging the mouse shoves and swirls nearby particles, injecting velocity and spin along the drag. The star of this piece is the per-frame particle solver, not any DOM animation — there is no GSAP here.
Tech
- Vanilla HTML / CSS / JS. The page can be served by Vite, but the physics library is not an npm
dependency.
p5.jsversion 1.4.0, loaded as a classic CDN<script>in<head>(not an ES import):
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>. The sketch runs in p5 global mode and uses the p5 globals (createVector, createCanvas, random, map, dist, lerp, color, width, height, mouseX/mouseY, mouseIsPressed, p5.Vector, TWO_PI, pow, abs, floor, ceil, triangle, rect, circle, …).
- The sketch itself lives in
./script.jsloaded as<script type="module" src="./script.js">.
Because it is an ES module, the p5 entry points are module-scoped, so at the bottom of the file you must explicitly bind them to window so p5's global-mode auto-init (fired on window load) can find them: window.setup = setup; window.draw = draw; window.windowResized = windowResized;.
- No GSAP, no ScrollTrigger, no Lenis, no Three.js. All motion is p5's
draw()loop integrating
the physics every frame. Do not reach for any animation library.
Layout / HTML
Minimal. A single overlay .header block; p5 injects the full-window <canvas> into <body> itself.
<body>
<div class="header">
<p>Is your big idea ready to go wild?</p>
<h1>Let's work <br /> together!</h1>
<button>Let's talk</button>
</div>
<script type="module" src="./script.js"></script>
</body>
The p5 canvas is created at windowWidth × windowHeight and sits behind the header (which is z-index: 2). Both the canvas and the CSS body share the exact same blue so the page reads as one continuous field.
Styling
- Palette: page + canvas background
#1a2ffb(electric cobalt blue). All particles are pure
white rgb(255,255,255). All text white #fff; the button is a white pill.
- Reset:
* { margin:0; padding:0; box-sizing:border-box; }. body—width:100%; height:100%; font-family:"Aeonik"(any clean geometric grotesque / system
sans is fine as fallback); background-color:#1a2ffb; display:flex; justify-content:center; align-items:center;.
.header— `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
display:flex; flex-direction:column; align-items:center; user-select:none; z-index:2;`.
.header p— white,text-transform:uppercase; font-weight:400; margin-bottom:1.5em;..header h1— white, `text-align:center; font-size:7.5vw; font-weight:400; line-height:100%;
margin-bottom:0.75em; (big but light-weight display text, broken into two lines via <br>`).
button— `border:none; outline:none; padding:1.5em 3em; text-transform:uppercase;
font-weight:500; background-color:#fff; border-radius:2em;` (white rounded pill).
The star effect — p5.js particle physics (be exhaustive)
Everything below is a direct port of the sketch; reproduce the constants, formulas, and per-frame order exactly.
Global config (module scope)
let particles = [];
const particleCount = 250;
const particleSize = 12;
const spacing = particleSize * 12; // 144 — interaction radius AND grid/spawn pitch
let gravity; // set in setup()
let deltaTime = 1 / 60; // recomputed each frame
let mousePrevX = 0, mousePrevY = 0; // previous-frame mouse, for drag velocity
setup()
createCanvas(windowWidth, windowHeight); frameRate(60);gravity = createVector(0, 2.2);(downward).background("#1a2ffb");- Spawn the particles in a centered grid near the top:
availableWidth = width * 0.95;cols = floor(availableWidth / spacing);startX = (width - cols * spacing) * 0.5;startY = height * 0.05;- Fill row by row: place
particleCountparticles,colsper row, at
x = startX + col*spacing + random(-5,5), y = startY + row*spacing + random(-5,5) (small jitter so the grid isn't perfectly rigid), incrementing row after each full row.
Particle constructor (x, y)
this.pos = createVector(x, y);
this.vel = createVector(random(-20, 20), random(-20, 20)); // small random launch
this.acc = createVector(0, 0);
this.color = color(255, 255, 255); // white
this.lastPos = createVector(x, y); // for render smoothing
this.densityFactor= 0; // rebuilt every frame by interact()
this.rotation = random(TWO_PI);
this.rotationVel = random(-0.1, 0.1);
this.shapeType = random(["triangle","square","circle"]); // fixed per particle
Particle.update() — integration order (do not reorder)
- Save
lastPos = pos. rotation += rotationVel * deltaTime;- Gravity, eased by local density (denser clumps fall slightly slower):
gravityScale = map(densityFactor, 0, 5, 1, 0.7); acc.add(p5.Vector.mult(gravity, 4 * gravityScale));
- Mouse drag force — only
if (mouseIsPressed): d = dist(pos.x, pos.y, mouseX, mouseY);withmaxDist = 250;- if
d < maxDist: mouseVel = createVector(mouseX - mousePrevX, mouseY - mousePrevY);(this-frame drag vector)densityScale = map(densityFactor, 0, 5, 1, 0.85);force = mouseVel.copy().mult(10 * densityScale);strength = pow(map(d, 0, maxDist, 1, 0), 1.75);(falloff, hardest at the cursor)force.mult(strength); acc.add(force);rotationVel += mouseVel.mag() * 0.01 * random(-1, 1);(fast drags add random spin)dampingFactor = map(densityFactor, 0, 5, 1, 1);(currently a no-op — always 1; keep it).
Then vel.add(p5.Vector.mult(acc, deltaTime * 15.0 * dampingFactor));
- Floor drag vs. air drag — if
pos.y > height - particleSize*2(near the floor):
vel.mult(0.92); vel.x *= 0.94; rotationVel *= 0.95; (heavy damping so the pile settles); else vel.mult(0.985); rotationVel *= 0.99; (light air damping).
pos.add(p5.Vector.mult(vel, deltaTime * 11.5));- Walls —
bounce = 0.45; buffer = particleSize;(12). Clampposto
[buffer, dimension - buffer] on each axis and reflect that velocity component toward the inside scaled by bounce: left/top → vel = abs(vel)*bounce, right/bottom → vel = -abs(vel)*bounce.
acc.mult(0);reset accumulator.densityFactor = 0;reset (rebuilt byinteract()this frame).
Particle.draw()
noStroke(); fill(this.color);- Render at the midpoint of last→current position for temporal smoothing:
renderX = lerp(lastPos.x, pos.x, 0.5); renderY = lerp(lastPos.y, pos.y, 0.5);
push(); translate(renderX, renderY); rotate(rotation);then draw atsize = particleSize(12):- triangle:
triangle(-size/2, size/2, size/2, size/2, 0, -size/2);(upward-pointing) - square:
rectMode(CENTER); rect(0, 0, size, size); - circle:
circle(0, 0, size); pop();
Particle.interact(other) — soft collision / fluid cohesion (the heart of the pile)
Only runs for pairs whose distance d = dist(this.pos, other.pos) is < spacing (144):
- Density accumulation:
densityIncrease = map(d, 0, spacing, 1.2, 0.1);add it to both
particles' densityFactor (this is what feeds the gravity/damping easing above).
- Separation direction:
force = p5.Vector.sub(this.pos, other.pos).normalize();(points from
other toward this). strength = pow(map(d, 0, spacing, 0.8, 0), 1.1); force.mult(strength);
- Overlap resolution —
overlap = spacing - d;ifoverlap > 0: correctionStrength = map(overlap, 0, spacing, 0.15, 0.25);
correction = force.copy().mult(overlap * correctionStrength);
- Boundary softening:
boundaryFactor = 1.0;if either particle's `pos.y > height -
particleSize*4, set boundaryFactor = 0.7; then correction.mult(boundaryFactor);` (gentler pushes near the floor so the heap compacts instead of exploding).
- Density-scaled positional push:
densityScale = map(this.densityFactor + other.densityFactor, 0, 10, 1, 0.9); correctionWeight = 0.15 * densityScale; this.pos.add(p5.Vector.mult(correction, correctionWeight)); other.pos.sub(p5.Vector.mult(correction, correctionWeight));
- Velocity blending (viscosity) — pull both velocities toward their average:
avgVel = p5.Vector.add(this.vel, other.vel).mult(0.5); velocityBlend = map(d, 0, spacing, 0.15, 0.02); velocityBlend *= map(this.densityFactor + other.densityFactor, 0, 10, 1.2, 0.95); if d < spacing*0.5 then velocityBlend *= 1.5; this.vel.lerp(avgVel, velocityBlend); other.vel.lerp(avgVel, velocityBlend);
- Acceleration impulse (cohesion/repulsion):
accForce = force.copy().mult(0.4);
this.acc.add(accForce); other.acc.sub(accForce);
draw() — per-frame loop with a spatial hash grid
background("#1a2ffb");(opaque repaint — no trails).deltaTime = 1 / frameRate();- Build the grid:
gridSize = spacing(144),grid = {}. For each particle: call
p.update(), compute key = floor(pos.x/gridSize) + "," + floor(pos.y/gridSize), and push the particle's index into grid[key].
- Resolve collisions over the 3×3 neighborhood: for every occupied cell
(gx,gy), for each of
the 9 neighbor cells (gx+dx, gy+dy) with dx,dy ∈ {-1,0,1}, for each ordered pair of indices i (from the cell) and j (from the neighbor) with i < j, call particles[i].interact(particles[j]). (The i < j guard makes each pair resolve once per frame.)
- Draw every particle:
for (p of particles) p.draw(); mousePrevX = mouseX; mousePrevY = mouseY;(store for next frame's drag velocity).
windowResized()
resizeCanvas(windowWidth, windowHeight); (particles keep their positions).
Assets / images
None. The visual is 100% the blue field, the white shapes, and the overlay type — no image assets.
Behavior notes
- Desktop pointer-driven. Idle behavior is autonomous: on load the grid drops, tumbles, and
settles into a wobbling white heap along the bottom under gravity. There is no auto-reset — the pile just keeps jostling with its residual velocity blending.
- Interaction requires holding the mouse button down (
mouseIsPressed) and dragging: that shoves
and swirls particles within a 250px radius of the cursor, hardest right at the pointer. A stationary or un-pressed mouse does nothing.
- Runs continuously at
frameRate(60); the piece never stops. TheO(n)spatial grid keeps the
~250-particle pairwise interaction cheap (medium perf cost).
- Full-window canvas, responsive via
windowResized. No reduced-motion handling in the original — all
motion is either gravity or user-driven.
- Particle shape is assigned once at spawn and never changes; expect a roughly even mix of triangles,
squares, and circles across the 250. </content> </invoke>