Buttons✨ Premium

Particle Trail

Every cursor move over the button drops a colored dot (4–12 px) that rises and fades out in 800 ms — a trail kept inside the button.

JSMouseParticles

You'll get access to the interactive demo with a free account.

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Buttons has 27 effects, including 2 free. Explore the category →

3 usage examples

Add to cart — beauty or fashion product card — Particle Trail example 1

① Add to cart — beauty or fashion product card

WhenProduct card in an online store: visual, name, price and a buy button.
WhyThe pink gradient and the trail fit a cosmetics or fashion universe; the hand's motion over the button is rewarded without delaying the click, since no waiting animation is added.
SettingsPalette cut down to three brand shades, diameter 3 to 8 px (6 * Math.random() + 3) for a narrow button, 40 ms threshold.
Get tickets — event or festival landing page — Particle Trail example 2

② Get tickets — event or festival landing page

WhenHero of an event site with dates, venue and a single ticketing button.
WhyOn a large button the trail has room, and the overflow hidden keeps it clean inside the rounded corners: the festive effect stays contained and never spills onto the title.
SettingsThreshold lowered to 20 ms (≈ 50 dots/s), diameter 6 to 16 px, the poster's accent color added to the array.
Reveal a promo code — welcome offer card — Particle Trail example 3

③ Reveal a promo code — welcome offer card

WhenPop-in or card reading “−20% on your first order” with a button that reveals the code.
WhyA reward button calls for playful feedback: dots that rise and fade evoke confetti without a library. The JS stays as is, only the button's styling changes.
SettingsLifetime 800 → 1200 ms (setTimeout and particleFade together), rise translateY(-32px), white removed from the array for a monochrome trail.

How it works

Every [data-particle-trail] element gets a mousemove listener with a rate limiter: Date.now() is compared with the last emission, and any event arriving less than 30 ms after the previous one is dropped (if (o − t < 30) return). Output is therefore capped at about 33 particles per second, whatever the mouse polling rate.

For each accepted event the script creates a <span class="particle">: diameter d = 4 + 8 × Math.random() (4 to 12 px), a color picked from a five-entry array (#ec4899, #f43f5e, #fb7185, #fda4af, #ffffff), left/top set to the cursor coordinates relative to the button (getBoundingClientRect), and a box-shadow: 0 0 d px in the same color for the glow. The span is appended inside the button.

On the CSS side, .particle is position: absolute (the button is position: relative), round (border-radius: 50%), pointer-events: none so it never intercepts the click, and animated by particleFade (.8s ease-out forwards): opacity 1 → 0 and scale(1) → scale(0) translateY(-20px) — the dot shrinks while rising 20 px. The overflow: hidden on .demo-btn clips the trail to the button's rounded corners.

Cleanup: one setTimeout(() => r.remove(), 800) per particle, matched to the animation length — the DOM never holds more than about thirty spans per button (33/s × 0.8 s). The hover lift is independent of the JS: .btn-particle-trail:hover applies translateY(-2px) and a pink shadow 0 10px 30px rgba(236,72,153,.4) through transition: .3s.

Accessibility

  • prefers-reduced-motion not handled: particles and lift animate regardless of the system preference. Add @media (prefers-reduced-motion: reduce) { .particle { display: none } .btn-particle-trail { transition: none } } — the JS keeps creating spans but they no longer render — or gate the listener on matchMedia('(prefers-reduced-motion: reduce)').matches.
  • The effect is mouse-only: no particles on touch or keyboard. The button remains a native <button>, focusable and activatable with Enter/Space; the trail must never carry information.
  • Insufficient contrast as shipped: white text on the pink → rose gradient measures 3.5:1 on #ec4899 and 3.7:1 on #f43f5e, below the 4.5:1 AA threshold for 16 px text. Switch to #db2777 → #e11d48 (4.6:1 and 4.7:1) or use an 18 px bold label (3:1 threshold).
  • Screen readers: particles are empty <span>s inserted into the <button> — with no text they don't change the accessible name. To be safe, add r.setAttribute('aria-hidden', 'true') at creation so the repeated insertions stay invisible to all assistive technology.
  • The code sets border: none without touching outline: the native focus ring stays. Touch target ≈ 52 px tall (16 px padding + 16 px text), above the 44 px minimum.

Browser compatibility

Requires ES2015 (arrow functions, template literals), getBoundingClientRect, Date.now and a CSS animation on opacity/transform — every browser since 2016. Zero dependencies.

Chrome 49+✓ Full
Firefox 45+✓ Full
Safari 10+✓ Full
Edge 79+✓ Full
Mobile iOS✓ Static (no hover)
Android Chrome✓ Static (no hover)

Without JavaScript the button keeps its gradient and its CSS hover lift; only the particles are gone. Without CSS animations (very old browsers), dots appear sharp and are then removed at once after 800 ms.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
30 ms threshold (JS — if (o - t < 30) return) 30 Maximum particle rate (1000 / 30 ≈ 33 per second). 15 ms doubles the density; 60 ms gives spaced-out, more readable dots.
Diameter 8 * Math.random() + 4 (JS) 4 to 12 px Dot size range. The glow (box-shadow) follows the diameter: bigger dots, bigger halo.
Color array (JS — f = [...]) #ec4899, #f43f5e, #fb7185, #fda4af, #ffffff Palette drawn at random for each dot. A single entry = solid trail; keep white in for sparks.
Lifetime (JS — setTimeout 800 + CSS — particleFade .8s) 800 ms / .8s Both values must stay equal: the setTimeout removes the span, the animation makes it vanish. If the animation is longer, the dot is cut off abruptly.
Trajectory (CSS — @keyframes particleFade, 100%) scale(0) translateY(-20px) End position of each dot. translateY(20px) makes dots fall; scale(1.6) with no translation gives popping bubbles.
overflow (CSS — .demo-btn) hidden Set to visible to let the trail escape the button — then leave margin around it, since dots are no longer clipped.
Hover (CSS — .btn-particle-trail:hover) translateY(-2px) + shadow 0 10px 30px rgba(236,72,153,.4) Lift independent of the JS. Delete the rule for a button that doesn't move: only the trail remains.

FAQ

Output is bounded by the 30 ms threshold and the 800 ms lifetime: at most 27 simultaneous spans per button, each animated on opacity and transform — two GPU-composited properties. The real cost is the DOM creation on each accepted event; raise the threshold to 50–60 ms if you have dozens of buttons on one page. On mobile nothing is created, as there is no mousemove.
Yes: a <span> is phrasing content allowed inside a <button>. What isn't allowed there are interactive elements (links, buttons). If your button is actually an <a>, the code works identically — the only requirement is position: relative on the element so the dots' left/top have a reference.
left/top place the span's top-left corner under the cursor, not its center: the dot spills by its radius down and to the right (2 to 6 px). To center it, add translate(-50%, -50%) to the transform of both particleFade steps, or subtract d / 2 from the coordinates in the JS.