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.