Sparkle trail
Small four point stars shed from the pointer, spin and drift down as they fade.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion, tokenColor } from "@/lib/motion";
import styles from "./demo.module.css";
const POOL = 240;
const IDLE_MS = 1800;
type Star = { alive: boolean; x: number; y: number; vx: number; vy: number; size: number; spin: number; angle: number; life: number; color: string };
// A four point star: curves pinched toward the centre between the points.
function star(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, angle: number) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, -r);
for (let i = 1; i <= 4; i++) {
const a = (i * Math.PI) / 2;
ctx.quadraticCurveTo(0, 0, Math.sin(a) * r, -Math.cos(a) * r);
}
ctx.fill();
ctx.restore();
}
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const reduced = usePrefersReducedMotion();
const inView = useInView(root);
useEffect(() => {
const el = root.current;
const cv = canvas.current;
const ctx = cv?.getContext("2d");
if (!el || !cv || !ctx || (!reduced && !inView)) return;
const colors = ["--accent", "--accent-soft", "--white", "--sand"].map((t) => tokenColor(el, t));
let width = 0, height = 0;
const resize = () => {
const dpr = Math.min(devicePixelRatio || 1, 2);
width = el.clientWidth;
height = el.clientHeight;
cv.width = Math.round(width * dpr);
cv.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
if (reduced) {
// Static end state: a loose arc of stars, drawn once.
const still = () => {
resize();
for (let i = 0; i < 22; i++) {
const t = i / 21;
ctx.globalAlpha = 0.35 + 0.65 * t;
ctx.fillStyle = colors[i % colors.length];
star(ctx, width * (0.15 + 0.7 * t), height * (0.7 - 0.35 * Math.sin(t * Math.PI)) + ((i * 37) % 19) - 9, 3 + (i % 4) * 2, i);
}
};
still();
const observer = new ResizeObserver(still);
observer.observe(el);
return () => observer.disconnect();
}
// The pool is allocated once; spawning revives a dead star instead of creating objects.
const stars: Star[] = Array.from({ length: POOL }, () => ({ alive: false, x: 0, y: 0, vx: 0, vy: 0, size: 0, spin: 0, angle: 0, life: 0, color: "" }));
let cursor = 0, raf = 0, previous = performance.now(), lastInput = -IDLE_MS, t = 0;
let last: { x: number; y: number } | null = null;
const spawn = (x: number, y: number) => {
const s = stars[cursor];
cursor = (cursor + 1) % POOL;
Object.assign(s, {
alive: true, x, y,
vx: (Math.random() - 0.5) * 40,
vy: -20 - Math.random() * 30,
size: 4 + Math.random() * 7,
spin: (Math.random() - 0.5) * 4,
angle: Math.random() * Math.PI,
life: 1,
color: colors[Math.floor(Math.random() * colors.length)],
});
};
// One star per 6px travelled keeps density even at any pointer speed.
const track = (x: number, y: number) => {
if (!last) last = { x, y };
const steps = Math.floor(Math.hypot(x - last.x, y - last.y) / 6);
for (let i = 1; i <= steps; i++) spawn(last.x + ((x - last.x) * i) / steps, last.y + ((y - last.y) * i) / steps);
if (steps) last = { x, y };
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - previous) / 1000);
previous = now;
if (now - lastInput > IDLE_MS) {
t += dt * 0.9;
track(width / 2 + Math.cos(t) * width * 0.3, height / 2 + Math.sin(t * 2) * height * 0.18);
}
ctx.clearRect(0, 0, width, height);
for (const s of stars) {
if (!s.alive) continue;
s.vy += 90 * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.angle += s.spin * dt;
s.life -= dt * 0.65;
if (s.life <= 0) {
s.alive = false;
continue;
}
ctx.globalAlpha = s.life;
ctx.fillStyle = s.color;
star(ctx, s.x, s.y, s.size * (0.5 + 0.5 * s.life), s.angle);
}
ctx.globalAlpha = 1;
raf = requestAnimationFrame(frame);
};
const move = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
const rect = el.getBoundingClientRect();
if (performance.now() - lastInput > IDLE_MS) last = null;
lastInput = performance.now();
track(event.clientX - rect.left, event.clientY - rect.top);
};
// A tap throws a small burst so touch users can play too.
const down = (event: PointerEvent) => {
if (event.pointerType !== "touch") return;
const rect = el.getBoundingClientRect();
for (let i = 0; i < 14; i++) spawn(event.clientX - rect.left + (Math.random() - 0.5) * 30, event.clientY - rect.top + (Math.random() - 0.5) * 30);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(el);
el.addEventListener("pointermove", move);
el.addEventListener("pointerdown", down);
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerdown", down);
};
}, [reduced, inView]);
return (
<div ref={root} className={styles.root}>
<canvas ref={canvas} className={styles.canvas} aria-hidden="true" />
<div className={styles.copy}>
<p className={styles.eyebrow}>New</p>
<p className={styles.title}>Instant payouts are live</p>
</div>
</div>
);
}
demo.module.css
.root { position: relative; width: 100%; height: 100%; display: grid; place-items: center; overflow: hidden; background: var(--ink); user-select: none; touch-action: manipulation; }
.canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.copy { position: relative; display: grid; justify-items: center; gap: 10px; padding: 16px; text-align: center; pointer-events: none; }
.eyebrow { padding: 3px 10px; border-radius: 999px; background: var(--accent-text); color: var(--white); font-size: 12px; font-weight: 650; }
.title { color: var(--white); font-size: clamp(22px, 4.2vw, 38px); line-height: 1.1; font-weight: 650; letter-spacing: -0.03em; }
@media (prefers-reduced-motion: reduce) { .canvas { opacity: .9; } }
Use it for
- Launch announcements and celebratory banners
- Dark hero panels where a little sparkle fits the brand
Avoid
- Serious or data heavy product screens
- Light backgrounds, where pale stars disappear
How it works
A canvas holds a fixed pool of 240 star objects that are revived round robin, so nothing is allocated while drawing. Pointer moves are split into 6px steps and each step revives one star with a small upward velocity and a random spin. A requestAnimationFrame loop applies gravity, rotates and fades each star and draws it as a four point path of quadratic curves.
Accessibility
The canvas is aria-hidden and the announcement stays real text. Touch moves are ignored so scrolling works; a tap throws a burst and an idle path keeps the trail moving on phones. Reduced motion draws a static arc of stars once and runs no loop, and the loop pauses off screen.