All components

Lusion Fluid Particle Simulation

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

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen p5.js particle physics simulation where hundreds of small shapes (triangles, squares, circles) fall under gravity and pile up with fluid-like collision resolution using a spatial grid. Pressing and dragging the mouse pushes and swirls nearby particles. It is not GSAP-driven — the physics integrate every frame inside p5's draw loop.

How it's built

Categoryinteractive
Techvanilla JS
Complexitypage
Performance costmedium
Mobile-safeyes

p5js particles physics fluid gravity canvas interactive simulation mouse

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

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.js version 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.js loaded 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; }.
  • bodywidth: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 particleCount particles, cols per 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)
  1. Save lastPos = pos.
  2. rotation += rotationVel * deltaTime;
  3. 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));

  1. Mouse drag force — only if (mouseIsPressed):
  2. d = dist(pos.x, pos.y, mouseX, mouseY); with maxDist = 250;
  3. if d < maxDist:
  4. mouseVel = createVector(mouseX - mousePrevX, mouseY - mousePrevY); (this-frame drag vector)
  5. densityScale = map(densityFactor, 0, 5, 1, 0.85);
  6. force = mouseVel.copy().mult(10 * densityScale);
  7. strength = pow(map(d, 0, maxDist, 1, 0), 1.75); (falloff, hardest at the cursor)
  8. force.mult(strength); acc.add(force);
  9. rotationVel += mouseVel.mag() * 0.01 * random(-1, 1); (fast drags add random spin)
  10. 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));

  1. 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).

  1. pos.add(p5.Vector.mult(vel, deltaTime * 11.5));
  2. Wallsbounce = 0.45; buffer = particleSize; (12). Clamp pos to

[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.

  1. acc.mult(0); reset accumulator. densityFactor = 0; reset (rebuilt by interact() 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 at size = 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 resolutionoverlap = spacing - d; if overlap > 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
  1. background("#1a2ffb"); (opaque repaint — no trails). deltaTime = 1 / frameRate();
  2. 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].

  1. 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.)

  1. Draw every particle: for (p of particles) p.draw();
  2. 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. The O(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>