You Might Also Like
Particle Explosion Click Button — Free HTML CSS JS Snippet
Particle Explosion Click Button · Buttons · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Particle Explosion Click Button — Canvas Physics Burst with Gravity, Fade & Squash Recoil

Micro-interactions are the small, disposable animations that make a click feel like it did something — a satisfying confirmation rather than a silent state change. This snippet builds a genuine physics-lite particle burst using the HTML5 Canvas API: clicking the button spawns 12 to 20 individually simulated particles that fly outward from the click point, fall under gravity, fade out over their lifetime, and get removed once fully transparent, while the button itself plays a squash-and-recoil animation to sell the impact.
Why canvas instead of DOM particles
A common naive approach animates particles as absolutely-positioned <div> elements. That works for a handful of particles, but every additional div is a full DOM node subject to layout, paint, and composite — with 15-20 particles animating every frame across many rapid clicks, DOM-based particles start to visibly stutter. This snippet instead draws every particle as an arc() fill directly onto a single <canvas> element, which is far cheaper: the browser rasterizes pixels rather than tracking and repainting dozens of live elements, and the entire particle system's memory footprint is just a plain JavaScript array of position/velocity objects, not DOM nodes.
The physics-lite simulation loop
Each particle object stores x, y, vx, vy (velocity), size, color, life (starting at 1, representing 100% opacity), and decay (how fast life depletes per frame). On each requestAnimationFrame tick, the update order is: apply gravity to vertical velocity (p.vy += GRAVITY), apply velocity to position (p.x += p.vx; p.y += p.vy), and decrement life by decay. This is Euler integration — the simplest possible numerical simulation, but visually convincing at these timescales and particle counts because the human eye doesn't need physically exact projectile motion to read "explosion." globalAlpha is set to p.life before drawing each particle so it visibly fades as its life depletes, and once life drops to zero or below, the particle is dropped from the array with particles.filter(p => p.life > 0) — this is the actual DOM-node-equivalent removal step, just applied to an array element instead of an element.
Randomizing angle and velocity for a natural burst shape
spawnBurst() picks a random angle in radians from 0 to 2π for every particle (Math.random() * Math.PI * 2) and derives vx/vy from that angle with Math.cos(angle) * speed and Math.sin(angle) * speed, where speed itself is randomized within a range. This trigonometric decomposition is what produces a genuinely radial 360-degree burst rather than particles all flying in the same direction — any angle is equally likely, and randomized speed and decay per particle mean no two particles trace an identical arc, which is what reads as "explosion" rather than a mechanical, repeating pattern.
High-DPI canvas sizing and the animation loop lifecycle
resizeCanvas() handles a detail that's easy to get wrong on Retina/high-DPI displays: it reads window.devicePixelRatio, sizes the canvas's backing buffer to rect.width * dpr while keeping the CSS style.width at the logical size, and calls ctx.setTransform(dpr, 0, 0, dpr, 0, 0) so all subsequent drawing calls can still use logical (CSS) pixel coordinates while rendering at full device resolution — skip this and the particles look soft and pixelated on Retina screens. The animation loop itself is lazily started and stopped: spawnBurst() only calls requestAnimationFrame(tick) if a loop isn't already running (if (!rafId)), and tick() stops rescheduling itself once the particle array empties, so the browser isn't burning frames on an idle canvas between clicks — clicking rapidly just adds more particles into the same already-running loop rather than starting redundant loops.
The button's own recoil animation
Independent of the canvas particles, the button element gets a .recoil class toggled via the standard remove-reflow-readd pattern (btn.classList.remove('recoil'); void btn.offsetWidth; btn.classList.add('recoil')) so the CSS @keyframes btn-recoil squash-and-rebound animation restarts cleanly even on rapid consecutive clicks, rather than silently failing to replay because the class was never actually removed from the DOM before being re-added in the same synchronous tick.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to trace exactly how a single particle's life value drives both its fade-out (via globalAlpha) and its eventual removal from the array — that update/draw/cull cycle is the core pattern behind every canvas particle system, not just this one. It's also worth asking the assistant to explain the devicePixelRatio canvas-sizing code line by line if you're not sure why it's there, since skipping it is a common source of blurry canvas rendering bugs. For extension, ask it to add mouse-position-based bursts instead of always centering on the button, a confetti-shaped particle variant (rotating rectangles instead of circles), or a reusable useParticleBurst hook so multiple buttons across a page can each trigger independent bursts without duplicating the canvas and physics code.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a button that emits a physics-based particle explosion on click, using an HTML5 canvas and vanilla JavaScript.
Requirements:
- Clicking the button spawns between 12 and 20 particles from the button's center (or the exact click point), each with a randomized launch angle across the full 360 degrees and a randomized initial speed, so the burst reads as a genuine radial explosion rather than particles all moving in one direction.
- Simulate real per-frame physics for each particle using requestAnimationFrame: update position by adding velocity each frame, apply a constant gravity value to vertical velocity each frame so particles arc and fall, and fade each particle's opacity over its lifetime using a life/decay value rather than a fixed-duration CSS animation.
- Remove each particle once its life/opacity reaches zero, and stop the requestAnimationFrame loop entirely once no particles remain alive — do not keep an idle animation loop running between bursts.
- Render particles on a canvas (not as individual absolutely-positioned DOM elements), and handle high-DPI/Retina displays correctly so particles are not blurry on displays with devicePixelRatio greater than 1.
- The button itself should play a distinct squash-and-recoil CSS animation on every click, correctly restarting even on rapid consecutive clicks (address the specific browser behavior that can silently prevent a CSS animation from replaying if a class is removed and re-added within the same synchronous task).
- Randomize particle color, size, and fade-out speed slightly so consecutive bursts don't look mechanically identical.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Click the button to trigger a burstEach click calls spawnBurst() with the button's center coordinates (computed via getBoundingClientRect()), spawning 12-20 particles with randomized angle, speed, size, color, and decay rate, then starts the requestAnimationFrame loop if it is not already running.
- 2Watch the physics simulation play outEvery frame, tick() applies GRAVITY to each particle's vertical velocity, moves it by its velocity, decrements its life, and draws it as a fading canvas arc. Particles are removed from the array once life reaches zero, and the loop stops rescheduling itself when the array is empty.
- 3Watch the button's own recoil animationIndependently of the particles, the button itself plays a squash-and-rebound @keyframes animation via the .recoil class, retriggered on every click using the classList.remove + void offsetWidth + classList.add pattern so it replays correctly even on rapid clicks.
- 4Adjust particle count, gravity, and colorsChange the count range in spawnBurst() (currently 12 + Math.floor(Math.random() * 9)) for a bigger or smaller burst, tweak the GRAVITY constant for a floatier or heavier fall, and edit the COLORS array to match your brand palette instead of the default multi-color confetti scheme.
- 5Trigger a burst from an arbitrary pointspawnBurst(cx, cy) accepts any canvas-relative coordinates, not just the button center — call it with e.clientX/e.clientY (adjusted for the canvas's bounding rect) to burst from the exact mouse click position instead of always the button's center.
- 6Export and reuse across multiple buttonsClick JSX to export a React component, then generalize the canvas/particle logic into a hook (e.g. useParticleBurst) that any button component can call, passing its own DOM ref so multiple explosion buttons can exist on the same page without sharing canvas state.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Canvas draws every particle as a rasterized shape within a single element, which is far cheaper than the browser tracking, laying out, and compositing 15-20 separate live DOM elements every animation frame — especially under rapid repeated clicks where multiple bursts might be animating simultaneously. The tradeoff is that canvas particles are not individually addressable DOM nodes (no CSS selectors, no event listeners per particle), which is a non-issue here since particles are purely decorative and never need to be individually interactive.
Each particle is a plain object with x/y position, vx/vy velocity, and a life value starting at 1. Every requestAnimationFrame tick applies three updates in order: p.vy += GRAVITY (gravity pulls the particle down over time), p.x += p.vx and p.y += p.vy (velocity moves the position), and p.life -= p.decay (the particle ages). ctx.globalAlpha is set to the current life value before drawing, so the particle visibly fades as life approaches zero, and particles are removed from the array once life drops to zero or below via particles.filter(p => p.life > 0).
By default, a canvas element's internal pixel buffer matches its CSS size 1:1, so on a display with devicePixelRatio of 2 or 3, the canvas is rendered at a lower resolution than the screen and then upscaled, producing visibly soft, blurry particles. resizeCanvas() fixes this by setting the actual canvas.width/height attributes to the CSS size multiplied by devicePixelRatio, keeping the CSS style.width/height at the original logical size, and calling ctx.setTransform(dpr, 0, 0, dpr, 0, 0) so all subsequent drawing coordinates can stay in simple logical pixels while rendering crisply at full device resolution.
The click handler currently computes cx/cy from btn.getBoundingClientRect() to always burst from the button's center. To burst from the exact cursor position instead, use the click event's e.clientX and e.clientY, subtract the canvas's own getBoundingClientRect().left/top to convert from viewport coordinates to canvas-relative coordinates, and pass those values to spawnBurst(cx, cy) instead of the button-center calculation.
No — every particle is a small plain object pushed into the particles array, and the array is filtered down to only living particles on every frame, so memory usage stays bounded by however many particles are currently alive and fading, typically well under a few hundred even with rapid clicking. The requestAnimationFrame loop also self-stops (rafId = null) once the array empties rather than continuing to run and consume CPU when the canvas is visually idle between click bursts.