AppCrumble mascotAppCrumble
← 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.