Particles
Sparkle exports all internal particle classes so you can use them directly in your own render loop. Each class follows the same interface: tick(dt), draw(ctx), and either isDead or isDone to signal removal.
Trail
A glowing line traveling from a start point to an end point. isDone when it arrives — no events or spark emission by default. Call collectSparks() to pick up the small trail sparks it emits.
Demo
Click anywhere to launch trails from all four edges.
<template>
<div
class="effect-demo effect-demo--clickable"
@click="onClick">
<canvas ref="canvasRef"></canvas>
<span class="effect-demo__hint">Click to set target</span>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { Spark, Trail } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let trails: Trail[] = [];
let sparks: Spark[] = [];
function randomEdgePoint(): { x: number; y: number } {
const edge = Math.floor(Math.random() * 4);
switch (edge) {
case 0:
return {x: Math.random() * width, y: 0};
case 1:
return {x: Math.random() * width, y: height};
case 2:
return {x: 0, y: Math.random() * height};
default:
return {x: width, y: Math.random() * height};
}
}
function onClick(evt: MouseEvent): void {
if (!canvasRef.value) {
return;
}
const rect = canvasRef.value.getBoundingClientRect();
const end = {x: evt.clientX - rect.left, y: evt.clientY - rect.top};
for (let i = 0; i < 5; i++) {
trails.push(new Trail(randomEdgePoint(), end, {hue: Math.random() * 360, width: 3, length: 8}));
}
}
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
ctx.globalCompositeOperation = 'lighter';
for (let i = sparks.length - 1; i >= 0; i--) {
sparks[i].tick(dt);
if (sparks[i].isDead) {
sparks.splice(i, 1);
} else {
sparks[i].draw(ctx);
}
}
for (let i = trails.length - 1; i >= 0; i--) {
trails[i].tick(dt);
sparks.push(...trails[i].collectSparks());
if (trails[i].isDone) {
trails.splice(i, 1);
} else {
trails[i].draw(ctx);
}
}
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
trails = [];
sparks = [];
ctx = null;
});
</script>import { Trail, Explosion } from '@basmilius/sparkle';
const trail = new Trail(
{x: canvas.width / 2, y: canvas.height},
{x: canvas.width / 2, y: 100},
{hue: 45, width: 3, length: 8}
);
// In your loop:
trail.tick(dt);
sparks.push(...trail.collectSparks());
if (trail.isDone) {
// Spawn an explosion at the arrival point
for (let i = 0; i < 60; i++) {
explosions.push(new Explosion(trail.position, trail.hue, 3, 'peony'));
}
} else {
trail.draw(ctx);
}SparklerParticle
A glowing spark with a circular trail. Accepts an explicit position, velocity, and RGB color.
Demo
Click anywhere to emit a burst of glowing sparks.
<template>
<div
class="effect-demo effect-demo--clickable"
@click="onClick">
<canvas ref="canvasRef"></canvas>
<span class="effect-demo__hint">Click anywhere</span>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { SparklerParticle } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let sparks: SparklerParticle[] = [];
const COLORS: [number, number, number][] = [
[255, 200, 50],
[255, 140, 20],
[255, 255, 180],
[255, 100, 80],
[100, 200, 255]
];
function onClick(evt: MouseEvent): void {
if (!canvasRef.value) {
return;
}
const rect = canvasRef.value.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
for (let i = 0; i < 40; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 2 + Math.random() * 6;
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
sparks.push(new SparklerParticle(
{x, y},
{x: Math.cos(angle) * spd, y: Math.sin(angle) * spd},
color,
{trailLength: 5, scale: 1.2}
));
}
}
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
ctx.globalCompositeOperation = 'lighter';
for (let i = sparks.length - 1; i >= 0; i--) {
sparks[i].tick(dt);
if (sparks[i].isDead) {
sparks.splice(i, 1);
} else {
sparks[i].draw(ctx);
}
}
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
sparks = [];
ctx = null;
});
</script>import { SparklerParticle } from '@basmilius/sparkle';
const spark = new SparklerParticle(
{x: 300, y: 400},
{x: 2, y: -4},
[255, 180, 50],
{trailLength: 5, scale: 1.2}
);
// Use 'lighter' composite for additive glow
ctx.globalCompositeOperation = 'lighter';
spark.tick(dt);
spark.draw(ctx);ConfettiParticle
A single confetti piece with physics (velocity, swing, rotation, flip) and one of 11 shapes.
Demo
Click anywhere to burst confetti in all shapes.
<template>
<div
class="effect-demo effect-demo--clickable"
@click="onClick">
<canvas ref="canvasRef"></canvas>
<span class="effect-demo__hint">Click anywhere</span>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import type { ConfettiShape } from '@basmilius/sparkle';
import { ConfettiParticle } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let particles: ConfettiParticle[] = [];
const SHAPES: ConfettiShape[] = ['bowtie', 'circle', 'crescent', 'diamond', 'heart', 'hexagon', 'ribbon', 'ring', 'square', 'star', 'triangle'];
const COLORS = ['#26ccff', '#a25afd', '#ff5e7e', '#88ff5a', '#fcff42', '#ffa62d', '#ff36ff'];
function onClick(evt: MouseEvent): void {
if (!canvasRef.value) {
return;
}
const rect = canvasRef.value.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
for (let i = 0; i < 60; i++) {
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
particles.push(new ConfettiParticle(
{x, y},
90,
shape,
color,
{spread: 120, startVelocity: 25, ticks: 180}
));
}
}
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].tick(dt);
if (particles[i].isDead) {
particles.splice(i, 1);
} else {
particles[i].draw(ctx);
}
}
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
particles = [];
ctx = null;
});
</script>import { ConfettiParticle } from '@basmilius/sparkle';
const particle = new ConfettiParticle(
{ x: 400, y: 300 },
90, // launch direction in degrees (90 = straight up)
'star',
'#ff4466',
{ spread: 120, startVelocity: 25, ticks: 180 }
);The 11 shapes are also exported as SHAPE_PATHS — normalized Path2D objects you can use in your own rendering code.
import { SHAPE_PATHS } from '@basmilius/sparkle';
ctx.save();
ctx.setTransform(size, 0, 0, size, x, y);
ctx.fillStyle = color;
ctx.fill(SHAPE_PATHS['heart']);
ctx.restore();BalloonParticle
A floating balloon with gradient body, gloss highlight, knot, and swaying string. Rises upward until it leaves the canvas.
Demo
Click anywhere to release a balloon.
<template>
<div
class="effect-demo effect-demo--clickable"
@click="onClick">
<canvas ref="canvasRef"></canvas>
<span class="effect-demo__hint">Click to release a balloon</span>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { BalloonParticle } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let balloons: BalloonParticle[] = [];
const COLORS: [number, number, number][] = [
[255, 68, 68],
[68, 136, 255],
[68, 204, 68],
[255, 204, 0],
[255, 136, 204],
[136, 68, 255]
];
function onClick(evt: MouseEvent): void {
if (!canvasRef.value) {
return;
}
const rect = canvasRef.value.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
balloons.push(new BalloonParticle({x, y}, color));
}
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
for (let i = balloons.length - 1; i >= 0; i--) {
balloons[i].tick(dt);
if (balloons[i].isDone) {
balloons.splice(i, 1);
} else {
balloons[i].draw(ctx);
}
}
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
balloons = [];
ctx = null;
});
</script>import { BalloonParticle } from '@basmilius/sparkle';
const balloon = new BalloonParticle(
{ x: 300, y: 400 },
[255, 68, 68], // RGB color
{ riseSpeed: 0.8, stringLength: 60 }
);RaindropParticle + SplashParticle
A line-rendered raindrop and the circular splashes it spawns on landing.
Demo
Continuous rain with wind and splashes.
<template>
<div class="effect-demo">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { RaindropParticle, SplashParticle } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let drops: RaindropParticle[] = [];
let splashes: SplashParticle[] = [];
let spawnInterval: ReturnType<typeof setInterval>;
const COLOR: [number, number, number] = [174, 194, 224];
const MAX_DROPS = 150;
const WIND = 0.25;
function spawnDrop(): void {
const depth = 0.3 + Math.random() * 0.7;
const vy = (3.5 + Math.random() * 5) * depth;
const vx = WIND * vy * 0.6;
drops.push(new RaindropParticle(
{x: Math.random() * width, y: -10},
{x: vx, y: vy},
COLOR,
{depth, groundY: height}
));
}
function spawnSplash(x: number, y: number): void {
splashes.push(...SplashParticle.burst({x, y}, COLOR));
}
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
ctx.fillStyle = 'rgba(22, 22, 24, 0.6)';
ctx.fillRect(0, 0, width, height);
for (let i = splashes.length - 1; i >= 0; i--) {
splashes[i].tick(dt);
if (splashes[i].isDead) {
splashes.splice(i, 1);
} else {
splashes[i].draw(ctx);
}
}
for (let i = drops.length - 1; i >= 0; i--) {
drops[i].tick(dt);
if (drops[i].isDead) {
spawnSplash(drops[i].position.x, height);
drops.splice(i, 1);
} else {
drops[i].draw(ctx);
}
}
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
for (let i = 0; i < MAX_DROPS; i++) {
spawnDrop();
}
spawnInterval = setInterval(spawnDrop, 30);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
clearInterval(spawnInterval);
then = 0;
drops = [];
splashes = [];
ctx = null;
});
</script>import { RaindropParticle, SplashParticle } from '@basmilius/sparkle';
const drop = new RaindropParticle(
{x: 200, y: 0},
{x: 1, y: 8}, // vx = wind, vy = fall speed
[174, 194, 224],
{depth: 0.7, groundY: canvas.height}
);
// On landing:
if (drop.isDead) {
const splashes = SplashParticle.burst(drop.position, [174, 194, 224]);
}FireflyParticle
A softly pulsing glow dot with organic Lissajous drift. Wraps around canvas edges. Create one sprite per color with createFireflySprite() and share it across all particles of that color.
Demo
40 fireflies drifting across a dark canvas.
<template>
<div class="effect-demo">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { createFireflySprite, FireflyParticle } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let fireflies: FireflyParticle[] = [];
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
ctx.globalCompositeOperation = 'lighter';
for (const firefly of fireflies) {
firefly.tick(dt);
firefly.draw(ctx);
}
ctx.globalCompositeOperation = 'source-over';
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
const greenSprite = createFireflySprite('#b4ff6a');
const amberSprite = createFireflySprite('#ffcc44');
const bounds = {width, height};
for (let i = 0; i < 40; i++) {
const sprite = Math.random() < 0.7 ? greenSprite : amberSprite;
fireflies.push(new FireflyParticle(
Math.random() * width,
Math.random() * height,
bounds,
sprite,
{size: 4 + Math.random() * 4}
));
}
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
fireflies = [];
ctx = null;
});
</script>import { FireflyParticle, createFireflySprite } from '@basmilius/sparkle';
const sprite = createFireflySprite('#b4ff6a');
const bounds = {width: canvas.width, height: canvas.height};
const firefly = new FireflyParticle(
Math.random() * canvas.width,
Math.random() * canvas.height,
bounds,
sprite,
{size: 6, glowSpeed: 1.2}
);
ctx.globalCompositeOperation = 'lighter';
firefly.tick(dt);
firefly.draw(ctx);ShootingStarSystem
A self-contained system that spawns and animates shooting stars at configurable intervals. Pass any () => number RNG — Math.random works fine.
import { ShootingStarSystem } from '@basmilius/sparkle';
const system = new ShootingStarSystem(
{interval: [60, 180], color: [200, 230, 255], trailLength: 20},
Math.random
);
// In your loop:
system.tick(dt, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'lighter';
system.draw(ctx);LightningSystem
A procedural lightning bolt generator that fires bolts at configurable intervals. Uses normalized coordinates (0–1) internally; pass width and height to draw() to scale to your canvas. Read flashAlpha to overlay a screen-wide light flash on each strike.
Demo
Automatic lightning bolts with branching and screen flash.
<template>
<div class="effect-demo">
<canvas ref="canvasRef"></canvas>
</div>
</template>
<script
setup
lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { LightningSystem } from '@basmilius/sparkle';
const canvasRef = ref<HTMLCanvasElement>();
let ctx: CanvasRenderingContext2D | null = null;
let width = 0;
let height = 0;
let running = false;
let animFrame = 0;
let then = 0;
let system: LightningSystem | null = null;
function loop(now: number): void {
if (!running || !canvasRef.value || !ctx || !system) {
return;
}
animFrame = requestAnimationFrame(loop);
const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
then = now;
canvasRef.value.width = width;
canvasRef.value.height = height;
ctx.fillStyle = '#090912';
ctx.fillRect(0, 0, width, height);
system.tick(dt);
if (system.flashAlpha > 0) {
ctx.fillStyle = `rgba(180, 200, 255, ${system.flashAlpha})`;
ctx.fillRect(0, 0, width, height);
}
system.draw(ctx, width, height);
}
onMounted(() => {
if (!canvasRef.value) {
return;
}
width = canvasRef.value.offsetWidth;
height = canvasRef.value.offsetHeight;
system = new LightningSystem(
{frequency: 0.5, color: [180, 200, 255], branches: true, flash: true},
Math.random
);
ctx = canvasRef.value.getContext('2d', {colorSpace: 'display-p3'});
running = true;
animFrame = requestAnimationFrame(loop);
});
onUnmounted(() => {
running = false;
cancelAnimationFrame(animFrame);
then = 0;
system = null;
ctx = null;
});
</script>import { LightningSystem } from '@basmilius/sparkle';
const system = new LightningSystem(
{frequency: 0.5, color: [180, 200, 255], branches: true, flash: true},
Math.random
);
// In your loop:
system.tick(dt);
if (system.flashAlpha > 0) {
ctx.fillStyle = `rgba(180, 200, 255, ${system.flashAlpha})`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
system.draw(ctx, canvas.width, canvas.height);