ClickSpark
Click-triggered particle burst wrapper.
Live demo
01Default β try it
Four spark shapes
Like β composes with stateful UI
Live playground β tune every prop in real time
Implementation
02<script>
import ClickSpark from '$lib/components/ClickSpark.svelte';
</script>
β
<ClickSpark sparkColor="#fbbf24" sparkCount={12} shape="star">
<button>Try the demo</button>
</ClickSpark>ClickSpark is a wrap-anything decoration. Each click spawns an independent burst of CSS-keyframed particles that origin from the actual click point β never the wrapper centre β so the burst feels tied to the gesture. Bursts self-clean on animation end, and reduced-motion users get the click semantics with no particles at all.
Logic explainer
03What Does It Do? (Plain English)
ClickSpark is a wrap-anything decoration that fires a tiny burst of particles every time the user clicks inside the wrapper. The wrapped element β a button, a link, a card, an image β keeps its normal click behaviour; ClickSpark just paints decorative sparks emanating from the exact pixel where the click landed. Each burst is independent and self-cleans, so even if the user mashes a button ten times in a row the bursts overlap cleanly without any state churn.
The shapes (dot, plus, line, star) and palette (any CSS colour) are configurable per instance. Reduced-motion users get the click semantics with no particles at all β the spark generation short-circuits before any DOM mutation.
How It Works (Pseudo-Code)
state:
bursts[] = [] // list of active bursts; each has { id, x, y, angles[] }
nextId = 0 // monotonically increasing id
on click(event):
if prefers-reduced-motion: return
rect = wrapper.getBoundingClientRect()
x = event.clientX β rect.left // relative to wrapper
y = event.clientY β rect.top
id = nextId++
angles = getSparkAngles(sparkCount) // [0, 360/n, 2*360/n, β¦]
bursts.push({ id, x, y, angles })
schedule(setTimeout, duration + 50ms):
bursts = bursts.filter(b => b.id !== id) // self-clean
render:
emit wrapper { onclick }
render { children }
for each burst in bursts (keyed by id):
div.burst at (left: burst.x, top: burst.y)
for each angle in burst.angles:
span.spark.spark-{shape} with CSS vars:
--angle, --distance, --duration, --color, --size, --easing
CSS:
.spark {
transform: rotate(var(--angle)) translateX(0) scale(1);
animation: spark-fly var(--duration) var(--easing) forwards;
}
@keyframes spark-fly {
0% { transform: rotate(var(--angle)) translateX(0) scale(1); opacity: 1; }
60% { opacity: 1; }
100% { transform: rotate(var(--angle)) translateX(var(--distance)) scale(0.4); opacity: 0; }
}The setTimeout after duration + 50 ms is deliberately slack β the +50 absorbs jitter so a spark that finishes a frame late doesn't briefly render at scale 0.4 before being garbage-collected.
The Core Concept: Even Angle Distribution Plus Rotate-Then-Translate
Two ideas combine to give cheap, correct radial bursts.
1. Evenly-spaced angles are computed by getSparkAngles(count):
angles[i] = i * (360 / count)For sparkCount = 8 you get [0Β°, 45Β°, 90Β°, 135Β°, 180Β°, 225Β°, 270Β°, 315Β°] β a clean octagon. Whatever count the consumer picks, the sparks are always evenly distributed; the function lives in <script module> so unit tests can verify it without rendering.
2. Rotate-first, translate-second sidesteps the per-spark cos/sin calls you would normally need to send each particle in its own direction. The CSS transform on each spark is:
transform: rotate(var(--angle)) translateX(0) // start
transform: rotate(var(--angle)) translateX(var(--distance)) // endReading the transform: the translateX(distance) walks the spark out along its own X axis, but because that translation is applied inside the rotated coordinate system, the spark actually moves in whatever direction the rotation pointed. So a spark assigned --angle: 90deg walks straight down once the rotation is applied; a spark at 45deg walks down-and-right; a spark at 0deg walks right. The browser does all the trigonometry on the GPU compositor; the JavaScript only had to assign the angles.
spark angle distribution (count = 8)
0Β°
β
315Β° βββΌββ 45Β°
β
ββ [β] ββ β burst origin (the click point)
β
225Β° βββΌββ 135Β°
β
180Β°The mid-keyframe 60% { opacity: 1 } keeps the spark fully visible for the bulk of its travel, then it fades sharply in the final 40%. This gives the burst a snappier feel than a linear opacity ramp would β the eye reads "particle dies" rather than "particle slowly fades the whole way".
CSS Animation Strategy
Every spark is a single <span> with five CSS custom properties driving the animation. There is no per-frame JavaScript and no requestAnimationFrame loop β the browser handles each spark independently.
.spark {
position: absolute;
width: var(--size);
height: var(--size);
margin-left: calc(var(--size) / -2); /* centred on the click point */
margin-top: calc(var(--size) / -2);
background: var(--color);
transform: rotate(var(--angle)) translateX(0) scale(1);
animation: spark-fly var(--duration) var(--easing) forwards;
}Four shape variants share the same animation, distinguished only by paint:
spark-dotβborder-radius: 50%spark-plusβ two crossed gradient bars, transparent backgroundspark-lineβ a thin pill (width: size * 0.3) that streaks outwardspark-starβ solid background masked by a five-pointedclip-path
pointer-events: none on the burst layer ensures the visual sparks never swallow subsequent clicks meant for the wrapped child. The wrapper itself is position: relative; display: inline-block so it does not disturb the parent's layout flow.
@media (prefers-reduced-motion: reduce) { .spark { display: none; } } is the belt-and-braces fallback β the click handler also short-circuits up front, so this only matters if the preference flips mid-flight.
State Flow Diagram
ββββββββββββββββββββ
β IDLE β
β bursts = [] β
ββββββββββ¬ββββββββββ
β click event
β (prefers-reduced-motion? skip β IDLE)
βΌ
ββββββββββββββββββββ
β FIRING β β bursts has β₯1 entry
β sparks animate β
β via CSS only β
ββββββββββ¬ββββββββββ
β duration + 50ms timer fires per-burst
βΌ
ββββββββββββββββββββ
β GARBAGE COL. β
β filter out id β
ββββββββββ¬ββββββββββ
β if any other bursts remain β FIRING, else β IDLE
βΌ
prefers-reduced-motion: reduce β click handler returns early; never enters FIRING.Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
sparkColor |
string |
'#ffffff' |
Any CSS colour. Drives the spark fill (and the gradient stops for the plus variant). |
sparkCount |
number |
8 |
Particles per click, distributed evenly around 360Β°. getSparkAngles is exported for unit testing. |
sparkSize |
number |
10 |
Particle size in pixels. Line and star variants scale internally off this. |
spreadRadius |
number |
60 |
How far each particle travels before reaching its endpoint. |
duration |
number |
500 |
Burst lifetime in milliseconds. Shorter = snappier; longer = lazier. |
easing |
string |
'cubic-bezier(0.25, 1, 0.5, 1)' |
Any CSS easing string. Drives the fly-out curve. |
shape |
'dot' | 'plus' | 'line' | 'star' |
'dot' |
Visual variant. All four use the same animation pipeline. |
class |
string |
'' |
Extra classes on the wrapper. |
children |
Snippet |
required | The element(s) to wrap. Their click semantics are preserved. |
Edge Cases
| Situation | Behaviour |
|---|---|
sparkCount = 0 |
getSparkAngles(0) returns []. The burst is created with no sparks β visible no-op. Cheaper to omit ClickSpark entirely. |
| Rapid repeated clicks | Each click pushes an independent burst; bursts may overlap visually but never share state. Each cleans itself on its own timer, so the array stays bounded by clicks-per-second Γ duration. |
| Click on disabled child button | The native click event still bubbles to the wrapper. The burst fires; the inner action does not. Fine for most cases. |
Wrapper rendered inside display: inline parent |
We force inline-block in CSS so positioning works. Adjacent inline content is unaffected. |
| Wrapped element overflows the wrapper | Clicks on the overflowed area do not fire (event listener is on the wrapper only). Wrap the overflowing element instead. |
prefers-reduced-motion: reduce |
Click handler returns early before any burst is created; no DOM mutation, no animation. The wrapped child still receives its click. |
| Burst fires near the edge of the wrapper | Sparks may animate outside the wrapper's box. The wrapper has position: relative but no overflow: hidden, so this is intentional β the burst is a moment of celebration, not a layout-bound element. |
| Component unmounts mid-burst | setTimeout callbacks reference state owned by the unmounted instance. Svelte tears down the DOM with the component, so the visible sparks disappear with their host; no memory leak. |
Dependencies
- Svelte 5 β
$statefor the reactive bursts array,$propsfor configuration,Snippetfor the children slot. <script module>β exportsgetSparkAnglesso unit tests can import the angle distributor without rendering the component.- Zero external libraries β no animation library, no icon library, no font CDN. All four spark shapes are pure CSS.
File Structure
src/lib/components/ClickSpark.svelte # implementation
src/lib/components/ClickSpark.md # this explainer
src/lib/components/ClickSpark.test.ts # unit tests, importing getSparkAngles
src/routes/clickspark/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
sparkColor | string | '#ffffff' | Hex / RGB colour for each particle. |
shape | 'dot' | 'plus' | 'line' | 'star' | 'dot' | Particle shape rendered via pure CSS. |
sparkCount | number | 8 | Number of particles per burst. |
sparkSize | number | 10 | Base particle size in pixels. |
spreadRadius | number | 60 | Distance particles travel from origin. |
duration | number | 500 | Burst lifetime in milliseconds. |
easing | string | 'cubic-bezier(0.25, 1, 0.5, 1)' | CSS easing curve applied to each particle's flight. |