← Back to Crumbs
ExperimentsReactTypeScriptCanvas
Shooting Stars
A starry-night hero background that drifts with your cursor and flings a shooting star when you move it fast - built to sit behind a headline and CTA.
Live preview
Launch something worth looking up for.
A dark, responsive hero background - move your cursor and watch the sky react.
Code
shooting-stars.tsx
"use client";
import * as React from "react";
export interface ShootingStarsSkyProps
extends React.HTMLAttributes<HTMLDivElement> {
density?: number;
}
interface Star {
x: number;
y: number;
r: number;
baseAlpha: number;
twinkleSpeed: number;
twinklePhase: number;
depth: number;
}
interface Streak {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
length: number;
}
export function ShootingStarsSky({
density = 0.00018,
className = "",
...props
}: ShootingStarsSkyProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let stars: Star[] = [];
let streaks: Streak[] = [];
const pointer = { x: 0, y: 0, targetX: 0, targetY: 0 };
let lastPointer = { x: 0, y: 0, t: 0 };
let lastStreakAt = 0;
let rafId = 0;
function resize() {
const rect = container!.getBoundingClientRect();
width = Math.max(rect.width, 1);
height = Math.max(rect.height, 1);
canvas!.width = width * dpr;
canvas!.height = height * dpr;
canvas!.style.width = `${width}px`;
canvas!.style.height = `${height}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
const count = Math.max(40, Math.round(width * height * density));
stars = Array.from({ length: count }, () => ({
x: Math.random() * width,
y: Math.random() * height,
r: Math.random() * 1.2 + 0.3,
baseAlpha: Math.random() * 0.5 + 0.4,
twinkleSpeed: Math.random() * 1.5 + 0.4,
twinklePhase: Math.random() * Math.PI * 2,
depth: Math.random() * 0.8 + 0.2,
}));
pointer.x = pointer.targetX = width / 2;
pointer.y = pointer.targetY = height / 2;
}
function spawnStreak(x: number, y: number, angle: number, speed: number) {
streaks.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 0,
maxLife: 0.7 + Math.random() * 0.4,
length: 50 + Math.random() * 60,
});
}
function handlePointerMove(e: PointerEvent) {
const rect = canvas!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
pointer.targetX = x;
pointer.targetY = y;
const now = performance.now();
if (lastPointer.t > 0) {
const dt = now - lastPointer.t;
const dx = x - lastPointer.x;
const dy = y - lastPointer.y;
const velocity = dt > 0 ? Math.hypot(dx, dy) / dt : 0;
if (velocity > 1.1 && now - lastStreakAt > 220) {
spawnStreak(x, y, Math.atan2(dy, dx), Math.min(velocity * 18, 16));
lastStreakAt = now;
}
}
lastPointer = { x, y, t: now };
}
function handlePointerLeave() {
pointer.targetX = width / 2;
pointer.targetY = height / 2;
}
let lastFrame = performance.now();
function frame(now: number) {
const dt = Math.min((now - lastFrame) / 1000, 0.05);
lastFrame = now;
if (now - lastStreakAt > 2600 + Math.random() * 2200) {
spawnStreak(
width * 0.2 + Math.random() * width * 0.6,
-10,
((55 + Math.random() * 30) * Math.PI) / 180,
7 + Math.random() * 4,
);
lastStreakAt = now;
}
pointer.x += (pointer.targetX - pointer.x) * 0.04;
pointer.y += (pointer.targetY - pointer.y) * 0.04;
const px = (pointer.x - width / 2) / width;
const py = (pointer.y - height / 2) / height;
ctx!.clearRect(0, 0, width, height);
for (const star of stars) {
star.twinklePhase += dt * star.twinkleSpeed;
const twinkle = (Math.sin(star.twinklePhase) + 1) / 2;
const alpha = star.baseAlpha * (0.55 + twinkle * 0.45);
const ox = -px * 16 * star.depth;
const oy = -py * 16 * star.depth;
ctx!.beginPath();
ctx!.arc(star.x + ox, star.y + oy, star.r, 0, Math.PI * 2);
ctx!.fillStyle = `rgba(255,255,255,${alpha})`;
ctx!.fill();
}
streaks = streaks.filter((streak) => {
streak.life += dt;
if (streak.life >= streak.maxLife) return false;
streak.x += streak.vx;
streak.y += streak.vy;
const t = streak.life / streak.maxLife;
const fade = t < 0.15 ? t / 0.15 : 1 - (t - 0.15) / 0.85;
const angle = Math.atan2(streak.vy, streak.vx);
const tailX = streak.x - Math.cos(angle) * streak.length;
const tailY = streak.y - Math.sin(angle) * streak.length;
const gradient = ctx!.createLinearGradient(
streak.x,
streak.y,
tailX,
tailY,
);
gradient.addColorStop(0, `rgba(255,255,255,${fade})`);
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx!.strokeStyle = gradient;
ctx!.lineWidth = 1.6;
ctx!.lineCap = "round";
ctx!.beginPath();
ctx!.moveTo(streak.x, streak.y);
ctx!.lineTo(tailX, tailY);
ctx!.stroke();
ctx!.beginPath();
ctx!.arc(streak.x, streak.y, 1.4, 0, Math.PI * 2);
ctx!.fillStyle = `rgba(255,255,255,${fade})`;
ctx!.fill();
return (
streak.x > -100 &&
streak.x < width + 100 &&
streak.y > -100 &&
streak.y < height + 100
);
});
rafId = requestAnimationFrame(frame);
}
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerleave", handlePointerLeave);
rafId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(rafId);
resizeObserver.disconnect();
canvas.removeEventListener("pointermove", handlePointerMove);
canvas.removeEventListener("pointerleave", handlePointerLeave);
};
}, [density]);
return (
<div
ref={containerRef}
{...props}
className={["overflow-hidden bg-[#04050d]", className].filter(Boolean).join(" ")}
>
<div className="relative h-full w-full">
<canvas ref={canvasRef} className="block h-full w-full" />
</div>
</div>
);
}
Usage
usage.tsx
import { ShootingStarsSky } from "@/components/shooting-stars";
export function Hero() {
return (
<section className="relative flex h-[520px] items-center justify-center overflow-hidden">
<ShootingStarsSky className="absolute inset-0" />
<div className="relative z-10 flex flex-col items-center gap-5 px-6 text-center">
<h1 className="max-w-xl text-4xl font-bold text-white">
Launch something worth looking up for.
</h1>
<p className="max-w-md text-white/70">
Move your cursor and watch the sky react.
</p>
<button className="rounded-full border border-white/25 bg-white/10 px-6 py-3 text-white">
Get started
</button>
</div>
</section>
);
}Dependencies
None - just React and Tailwind.
Responsive
Fills whatever container you give it and redraws itself on resize via a ResizeObserver - works as a small card accent or a full-viewport hero background. The wrapping element needs an explicit height.
Dark mode
Always renders as a dark night sky, regardless of the site's own theme - there's no light-mode variant, since a starfield wouldn't read as one.