All components

Interactive Fluid Particle Logo

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

Open live demo ↗ Raw prompt (.md)

What it does

Full-viewport WebGL canvas that rasterizes a PNG logo into thousands of soft GL_POINTS particles on a dark background. Moving the mouse applies an inverse-square repulsion force to nearby particles, which scatter and then spring back to their origin via velocity damping and a return-force, capped by an exponentially damped max-displacement clamp; no GSAP, raw WebGL physics on requestAnimationFrame.

How it's built

Categoryinteractive
Techvanilla JS
Complexitysection
Performance costlight
Mobile-safedesktop-first

webgl particles logo mouse physics shaders hero gpgpu repulsion

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

Interactive Fluid Particle Logo

Goal

Build a full-viewport WebGL hero where a PNG logo is rasterized pixel-by-pixel into tens of thousands of soft round GL_POINTS particles floating on a near-black background. The star effect: moving the mouse repels nearby particles with an inverse-square force, scattering them fluidly, and each particle then springs back to its home position through velocity damping plus a constant return force — with an exponentially damped clamp that stops any particle drifting more than ~100px from home. The whole simulation is raw WebGL + a hand-rolled physics loop on requestAnimationFrame. No GSAP, no Three.js, no libraries at all.

Tech

Vanilla HTML/CSS/JS with ES module scripts. Zero npm dependencies — use the raw canvas.getContext("webgl") API directly. Put the two GLSL source strings in a sibling module and import them:

import { vertexShader, fragmentShader } from "./shaders.js";

Layout / HTML

Minimal — the entire page is one canvas:

<body>
  <canvas id="canvas"></canvas>
  <script type="module" src="./script.js"></script>
</body>

Page title: "Interactive Fluid Particle Logo".

Styling

Only a global reset — nothing else:

* { margin: 0; padding: 0; box-sizing: border-box; }

The canvas is sized by JS (below) and the background color is painted by WebGL's clearColor, not CSS.

Config (top of script.js)

Centralize every tunable in one object; all the exact values matter:

const config = {
  logoPath: "/path/logo.png",   // square PNG with transparency
  logoSize: 1250,               // sampling grid resolution (px)
  logoColor: "#404040",         // tint multiplied onto the logo pixels
  canvasBg: "#141414",          // WebGL clear color
  distortionRadius: 3000,       // mouse influence radius (device px, used squared)
  forceStrength: 0.0035,        // scales the repulsion acceleration
  maxDisplacement: 100,         // max px a particle may drift from home
  returnForce: 0.025,           // spring factor pulling back to origin
};

Canvas + WebGL setup

  • Canvas sizing (DPR-aware): canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr; with canvas.style.width/height set to the CSS pixel size (dpr = window.devicePixelRatio || 1). All simulation coordinates live in device pixels.
  • Context: canvas.getContext("webgl", { alpha: true, depth: false, stencil: false, antialias: true, powerPreference: "high-performance", premultipliedAlpha: false }).
  • Blending: gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  • Compile the two shaders, link one program. No depth test, no textures.

Shaders (shaders.js)

Vertex shader — positions arrive in device-pixel space; convert to clip space with a Y-flip, forward the per-particle color, fixed point size 3.5:

precision highp float;
uniform vec2 u_resolution;
attribute vec2 a_position;
attribute vec4 a_color;
varying vec4 v_color;
void main() {
    vec2 zeroToOne = a_position / u_resolution;
    vec2 clipSpace = (zeroToOne * 2.0 - 1.0);
    v_color = a_color;
    gl_Position = vec4(clipSpace * vec2(1.0, -1.0), 0.0, 1.0);
    gl_PointSize = 3.5;
}

Fragment shader — each point is a soft anti-aliased disc: radial alpha falloff via smoothstep, discarding fully transparent particles:

precision highp float;
varying vec4 v_color;
void main() {
    if (v_color.a < 0.01) discard;
    vec2 coord = gl_PointCoord - vec2(0.5);
    float dist = length(coord);
    float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
    gl_FragColor = vec4(v_color.rgb, v_color.a * alpha);
}

Building the particles from the logo

  1. Load the PNG with new Image(). In onload, draw it onto an offscreen 2D canvas of logoSize × logoSize (1250×1250) at scale 0.9, centered (size = 1250 * 0.9, offset = (1250 - size) / 2).
  2. getImageData over the full 1250×1250 grid. Loop rows i (y) and columns j (x); pixelIndex = (i * logoSize + j) * 4.
  3. For every pixel with alpha > 10, create one particle at:
  4. particleX = canvas.width/2 + (j - logoSize/2) * 1.0
  5. particleY = canvas.height/2 + (i - logoSize/2) * 1.0

i.e. 1 particle per source pixel, 1 device-pixel spacing, logo centered in the canvas (so the point cloud spans up to 1250 device px).

  1. Per-particle color = original pixel RGB (normalized 0–1) multiplied channel-wise by the #404040 tint, alpha = original pixel alpha / 255 (this keeps anti-aliased logo edges soft). Result: dark-grey logo on the #141414 background — subtle, low-contrast.
  2. Each particle object stores { originalX, originalY, velocityX: 0, velocityY: 0 }. Current positions live in a flat Float32Array (positionArray, 2 floats per particle) uploaded to a gl.DYNAMIC_DRAW buffer; colors in a Float32Array (4 floats per particle) uploaded once as gl.STATIC_DRAW.
  3. Only after particles exist, start the rAF loop.

Expect a large count (a bold logo yields ~100k–400k points) — the physics loop must be plain indexed for loops over typed arrays to stay 60fps.

The interaction physics (the important part — be exact)

Activity gate — simulation sleeps when idle

Keep a counter animationCount = 0. Every mousemove sets animationCount = 300; each physics tick decrements it and the whole physics update early-returns when it reaches 0 (rendering continues every frame regardless). Net effect: the fluid simulation runs for ~300 frames (~5s) after the last mouse movement, letting every particle settle home, then freezes for free performance.

Mouse tracking

mousemove on document, converted to device pixels relative to the canvas:

mouse.x = (event.clientX - rect.left) * dpr;
mouse.y = (event.clientY - rect.top) * dpr;
animationCount = 300;
Per-frame physics (for each particle i, current pos read from positionArray)

Let radiusSquared = distortionRadius² = 9,000,000.

  1. Inverse-square repulsiondeltaX/deltaY = mouse − current, distanceSquared = deltaX² + deltaY². If 0 < distanceSquared < radiusSquared:
  2. force = -radiusSquared / distanceSquared (negative → pushes away from the cursor; explodes near the cursor, fades with distance).
  3. angle = atan2(deltaY, deltaX).
  4. Displacement-based falloff: forceMultiplier = max(0.1, 1 − distFromOrigin / (maxDisplacement * 2)) where distFromOrigin is the particle's current distance from its home — particles already far from home resist further pushing (floor at 0.1).
  5. velocityX += force * cos(angle) * forceStrength * forceMultiplier (same for Y with sin). forceStrength = 0.0035.
  6. Friction: velocityX *= 0.82; velocityY *= 0.82; every tick, unconditionally.
  7. Spring integration: targetX = currentX + velocityX + (originalX − currentX) * returnForce (returnForce = 0.025 — a lazy constant pull home; same for Y).
  8. Soft max-displacement clamp: compute the target's offset from home; if its length distFromOrigin > maxDisplacement (100):
  9. excess = distFromOrigin − maxDisplacement
  10. scale = maxDisplacement / distFromOrigin
  11. dampedScale = scale + (1 − scale) * exp(−excess * 0.02) — an exponentially damped clamp: slight overshoot allowed near the limit, hard stop far past it (no visible snapping).
  12. position = original + offset * dampedScale, and bleed energy: velocity *= 0.7 (both axes).

Otherwise position = target.

  1. After the loop, re-upload positions with gl.bufferSubData on the dynamic buffer.
Render (every frame)

gl.viewport(0, 0, canvas.width, canvas.height), clear to #141414 (alpha 1), bind program, set u_resolution = (canvas.width, canvas.height), bind position attrib (2 floats) and color attrib (4 floats), then gl.drawArrays(gl.POINTS, 0, particleCount).

Resulting feel

Sweeping the cursor through the logo blasts a hole through the particles; they curl outward fluidly (the inverse-square profile makes close particles fly and distant ones barely stir), then drift back over ~1–2 seconds like iron filings re-forming, with no elastic wobble thanks to the 0.82 friction + damped clamp.

Resize behavior

On window.resize, re-run the canvas sizing, then reposition the existing particles as a centered √N × √N square grid at 1px spacing (row = floor(i / dim), col = i % dim, positioned around the new canvas center), reset positionArray to those spots and re-upload with bufferSubData. (This is a deliberate cheap re-center — after a resize the cloud reflows into a solid square block rather than re-sampling the logo; keep this behavior.)

Assets / images

  • 1 image — a square (1:1) PNG logo/mark on a fully transparent background: a simple, bold, geometric shape (e.g. an abstract mountain/triangle mark) in a light grey, ideally ≥1000px so the 1250-grid sampling stays crisp. Solid silhouette with clean anti-aliased edges works best; the code multiplies its color by #404040, so a light source logo lands as dark grey on the near-black canvas. No real brand.

Behavior notes

  • Desktop / mouse-driven — there is no touch fallback; on touch devices the logo simply renders statically.
  • The logo renders at device-pixel scale (1250 device px wide at most), so on a 2× display it appears ~625 CSS px wide — that's expected.
  • No GSAP, no ScrollTrigger, no smooth-scroll — one rAF loop drives everything.
  • The animationCount gate is the only perf guard; keep it, along with powerPreference: "high-performance" and the typed-array buffers.