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;withcanvas.style.width/heightset 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
- Load the PNG with
new Image(). Inonload, draw it onto an offscreen 2D canvas oflogoSize × logoSize(1250×1250) at scale 0.9, centered (size = 1250 * 0.9,offset = (1250 - size) / 2). getImageDataover the full 1250×1250 grid. Loop rowsi(y) and columnsj(x);pixelIndex = (i * logoSize + j) * 4.- For every pixel with alpha > 10, create one particle at:
particleX = canvas.width/2 + (j - logoSize/2) * 1.0particleY = 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).
- Per-particle color = original pixel RGB (normalized 0–1) multiplied channel-wise by the
#404040tint, alpha = original pixel alpha / 255 (this keeps anti-aliased logo edges soft). Result: dark-grey logo on the#141414background — subtle, low-contrast. - Each particle object stores
{ originalX, originalY, velocityX: 0, velocityY: 0 }. Current positions live in a flatFloat32Array(positionArray, 2 floats per particle) uploaded to agl.DYNAMIC_DRAWbuffer; colors in aFloat32Array(4 floats per particle) uploaded once asgl.STATIC_DRAW. - 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.
- Inverse-square repulsion —
deltaX/deltaY = mouse − current,distanceSquared = deltaX² + deltaY². If0 < distanceSquared < radiusSquared: force = -radiusSquared / distanceSquared(negative → pushes away from the cursor; explodes near the cursor, fades with distance).angle = atan2(deltaY, deltaX).- Displacement-based falloff:
forceMultiplier = max(0.1, 1 − distFromOrigin / (maxDisplacement * 2))wheredistFromOriginis the particle's current distance from its home — particles already far from home resist further pushing (floor at 0.1). velocityX += force * cos(angle) * forceStrength * forceMultiplier(same for Y withsin).forceStrength = 0.0035.- Friction:
velocityX *= 0.82; velocityY *= 0.82;every tick, unconditionally. - Spring integration:
targetX = currentX + velocityX + (originalX − currentX) * returnForce(returnForce = 0.025 — a lazy constant pull home; same for Y). - Soft max-displacement clamp: compute the target's offset from home; if its length
distFromOrigin > maxDisplacement (100): excess = distFromOrigin − maxDisplacementscale = maxDisplacement / distFromOrigindampedScale = 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).- position =
original + offset * dampedScale, and bleed energy:velocity *= 0.7(both axes).
Otherwise position = target.
- After the loop, re-upload positions with
gl.bufferSubDataon 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
animationCountgate is the only perf guard; keep it, along withpowerPreference: "high-performance"and the typed-array buffers.
Images
This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/interactive-particle-logo/logo.png
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--bg,--ink,--muted,--accent,--line. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.
Adapting this to React
Everything above describes a mount(config) / destroy() pair that already has the shape of an effect and its cleanup: mount finds the canvas, opens the WebGL context, compiles the two shaders, kicks off the logo Image load, and returns a function that cancels the frame, strips the three listeners, and frees every GPU object it created. The dispatch at the bottom of the file — the window.MP branch versus the plain-document fallback — has nothing to do with how React schedules work, and it is exactly what has to go.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Call this file's mount() without wiring its own destroy() back as the effect's cleanup, and the second mount finds the first one still alive: two requestAnimationFrame chains each reading and writing their own positionArray, two mousemove listeners on document each driving a different animationCount gate, and — because a browser only grants a page a handful of live WebGL contexts — a real chance that the second canvas.getContext("webgl") call returns null instead of a context at all. None of this reproduces in a production build, because only development double-invokes.
*(1) The entry point* — the else branch is the one that always runs in a React host (window.MP is this catalogue's own editor bridge and won't exist there); it checks document.readyState before subscribing to DOMContentLoaded, a guard that exists only to survive being loaded late into a plain document. useEffect already runs after commit, so the guard is dead weight. Delete the whole if (window.MP...) { ... } else { ... } dispatch and the boot closure with it, and move mount's body directly into a useEffect with an empty dependency array, feeding it this component's own props merged over DEFAULTS instead of the bare copy boot builds today.
*(2) Element lookups* — there is exactly one DOM query in the file, document.getElementById("canvas"), and mount does something deliberate with what it finds: existing.cloneNode(false); existing.replaceWith(canvas). That swap exists because destroy() ends by calling WEBGL_lose_context's loseContext(), and a <canvas> whose context has been force-lost never returns a working one from getContext() again — without a fresh node, a second mount() against the same element draws nothing, forever. Give the component a root ref on the <canvas> JSX itself renders, and drop getElementById and the clone-and-replace with it — but do not carry loseContext() into the React cleanup unchanged. React hands you back the *same* live node across a StrictMode remount (there is no second <canvas> to swap onto), so force-losing its context there reproduces the exact black-canvas failure the vanilla clone trick was built to dodge. Free the GPU objects (below) and leave the context itself alive; the next mount's setup reuses it instead of reacquiring one.
*(3) Cleanup* — this piece has no GSAP, ScrollTrigger, Lenis, or SplitText. The one thing that has to be cancelled by hand is the frame loop: keep the handle requestAnimationFrame returns (frame above) and call cancelAnimationFrame on it in the effect's cleanup, or the StrictMode-orphaned loop keeps calling updatePhysics/render against a positionArray and gl the current mount has already replaced. Everything else destroy() does to the GPU objects — deleteBuffer on both positionBuffer and colorBuffer, detachShader/deleteShader for vs and fs, deleteProgram — is already correct and carries over as-is (minus loseContext(), per above); it just has to run from the function the effect returns rather than from a destroy() a caller has to remember to invoke.
One more continuation worth preserving rather than trimming: loadLogo()'s Image.onload checks if (destroyed) return; before turning pixels into particles, because the PNG can finish decoding after the owning mount() has already been torn down — a StrictMode remount is fast enough to land inside a single image decode. Keep exactly this shape in the effect: a cancellation flag the cleanup sets, checked at the top of onload before it calls createParticles, so a decode that resolves after unmount does not build a particle buffer and start animate() against a context and buffers the cleanup has already freed.
This component has no three anywhere — it drives a raw canvas.getContext("webgl"), not a THREE.WebGLRenderer, and there is no scene graph — so the R3F mapping does not apply here.