Skip to content

Advanced Usage

Sparkle exposes its internal particle classes and standalone systems so you can build fully custom simulations without using a pre-built effect class. You pick the particles you want, manage the canvas yourself, and write your own render loop.

The basic pattern

Every custom effect needs: a canvas, a requestAnimationFrame loop, and a list of particles you tick and draw each frame.

Demo

Click anywhere to fire an explosion.

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 { Explosion, EXPLOSION_CONFIGS } 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 explosions: Explosion[] = [];

    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 hue = Math.random() * 360;
        const config = EXPLOSION_CONFIGS['peony'];
        const count = Math.floor(config.particleCount[0] + Math.random() * (config.particleCount[1] - config.particleCount[0]));

        for (let i = 0; i < count; i++) {
            explosions.push(new Explosion({x, y}, hue, 3, 'peony'));
        }
    }

    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 = explosions.length - 1; i >= 0; i--) {
            explosions[i].tick(dt);

            if (explosions[i].isDead) {
                explosions.splice(i, 1);
            } else {
                explosions[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;
        explosions = [];
        ctx = null;
    });
</script>
typescript
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d', {colorSpace: 'display-p3'});
const particles = [];
let then = 0;

function loop(now: number) {
    requestAnimationFrame(loop);

    const dt = then > 0 ? (now - then) / (1000 / 60) : 1;
    then = now;

    // Setting canvas.width clears the canvas and resets context state
    canvas.width = canvas.offsetWidth;
    canvas.height = canvas.offsetHeight;

    for (let i = particles.length - 1; i >= 0; i--) {
        particles[i].tick(dt);

        if (particles[i].isDead || particles[i].isDone) {
            particles.splice(i, 1);
        } else {
            particles[i].draw(ctx);
        }
    }
}

requestAnimationFrame(loop);

All particle classes follow the same interface: tick(dt), draw(ctx), and either isDead or isDone to signal when to remove them. The dt value is a frame-normalized delta — 1.0 at 60 FPS, 2.0 at 30 FPS — which keeps physics frame-rate independent.