Sparkles
Canvas particle field that drifts and twinkles behind any container. Density scales with size, colors resolve from tokens, and it pauses offscreen and under reduced-motion. No dependencies.
Example
Installation
npx shadcn@latest add https://hirael.com/r/sparkles.jsonUsage
import { Sparkles } from "@/components/sparkles"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<Sparkles />
+ native element props| Prop | Type | Default |
|---|---|---|
densityParticles per 10,000 px² (a 100×100 tile) of canvas. The default (2) puts about 200 particles on a 1000×1000 area and scales with the container size. | number | 2 |
sizeLargest particle radius, in px. Each particle picks a size up to this. | number | 1.2 |
speedDrift speed, in px per second at 1×. | number | 0.4 |
colorParticle color. CSS variables resolve against the container, so tokens work. | string | 'var(--foreground)' |
opacityBrightest alpha a particle twinkles up to (0–1). | number | 1 |
Component source
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
interface SparklesProps extends React.ComponentProps<'div'> {
/**
* Particles per 10,000 px² (a 100×100 tile) of canvas. The default (2) puts
* about 200 particles on a 1000×1000 area and scales with the container size.
*/
density?: number;
/** Largest particle radius, in px. Each particle picks a size up to this. */
size?: number;
/** Drift speed, in px per second at 1×. */
speed?: number;
/** Particle color. CSS variables resolve against the container, so tokens work. */
color?: string;
/** Brightest alpha a particle twinkles up to (0–1). */
opacity?: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
r: number;
phase: number;
rate: number;
}
const resolveColor = (el: HTMLElement, color: string) => {
const match = color.match(/^var\((--[\w-]+)\)$/);
if (!match) return color;
return getComputedStyle(el).getPropertyValue(match[1]).trim() || color;
};
const Sparkles = ({
density = 2,
size = 1.2,
speed = 0.4,
color = 'var(--foreground)',
opacity = 1,
className,
...props
}: SparklesProps) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useLayoutEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!container || !canvas || !ctx) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const fill = resolveColor(container, color);
let particles: Particle[] = [];
let width = 0;
let height = 0;
let raf = 0;
let last = 0;
let isVisible = true;
let isRunning = false;
const seed = () => {
const count = Math.round(((width * height) / 10_000) * density);
particles = Array.from({ length: count }, () => {
const angle = Math.random() * Math.PI * 2;
const v = speed * (0.3 + Math.random() * 0.7);
return {
x: Math.random() * width,
y: Math.random() * height,
vx: Math.cos(angle) * v,
vy: Math.sin(angle) * v,
r: size * (0.4 + Math.random() * 0.6),
phase: Math.random() * Math.PI * 2,
rate: 0.6 + Math.random() * 1.6,
};
});
};
const draw = (dt: number, t: number) => {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = fill;
for (const p of particles) {
p.x = (p.x + p.vx * dt + width) % width;
p.y = (p.y + p.vy * dt + height) % height;
const twinkle = (Math.sin(t * p.rate + p.phase) + 1) / 2;
ctx.globalAlpha = opacity * (0.1 + 0.9 * twinkle);
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
last = now;
draw(dt, now / 1000);
raf = requestAnimationFrame(frame);
};
const start = () => {
if (isRunning || reduced) return;
isRunning = true;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
isRunning = false;
cancelAnimationFrame(raf);
};
const sync = () => {
if (isVisible && document.visibilityState === 'visible') start();
else stop();
};
const resize = () => {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
width = rect.width;
height = rect.height;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
seed();
if (reduced) draw(0, 0);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(container);
const io = new IntersectionObserver(([entry]) => {
isVisible = entry.isIntersecting;
sync();
});
io.observe(container);
document.addEventListener('visibilitychange', sync);
sync();
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener('visibilitychange', sync);
};
}, [density, size, speed, color, opacity]);
return (
<div
ref={containerRef}
data-slot="sparkles"
aria-hidden
className={cn('pointer-events-none absolute inset-0', className)}
{...props}
>
<canvas ref={canvasRef} className="block size-full" />
</div>
);
};
export { Sparkles };
Shipped