Skip to content

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.

Click to set target

<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>
typescript
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.

Click anywhere

<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>
typescript
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.

Click anywhere

<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>
typescript
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.

typescript
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.

Click 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>
typescript
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>
typescript
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>
typescript
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.

typescript
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>
typescript
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);