Meteor Shower Animation

PreviousNext

Beautiful meteor shower animation background built with React and Canvas. Add dynamic falling meteors to your site with smooth, customizable visuals and minimal performance impact.

Docs
bunduicomponent

Preview

Loading preview…
examples/motion/backgrounds/meteor-shower-animation/01/meteor-shower.tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";

interface Meteor {
  x: number;
  y: number;
  size: number;
  speed: number;
  angle: number;
  opacity: number;
  tail: { x: number; y: number }[];
  tailLength: number;
}

interface ThemeColors {
  background: string;
  meteorHead: string;
  meteorTailStart: string;
  meteorTailMiddle: string;
  meteorTailEnd: string;
}

export default function MeteorShower({
  children,
  className
}: {
  children: React.ReactNode;
  className?: string;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const meteorsRef = useRef<Meteor[]>([]);
  const animationRef = useRef<number>(0);
  const { theme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
  }, []);

  useEffect(() => {
    const updateDimensions = () => {
      if (typeof window !== "undefined") {
        setDimensions({
          width: window.innerWidth,
          height: window.innerHeight
        });
      }
    };

    updateDimensions();
    window.addEventListener("resize", updateDimensions);

    return () => {
      window.removeEventListener("resize", updateDimensions);
    };
  }, []);

  useEffect(() => {
    if (!canvasRef.current || dimensions.width === 0 || dimensions.height === 0 || !mounted) return;

    const canvas = canvasRef.current;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    canvas.width = dimensions.width;
    canvas.height = dimensions.height;

    const isDark =
      resolvedTheme === "dark" ||
      document.documentElement.classList.contains("dark") ||
      (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);

    const themeColors: ThemeColors = isDark
      ? {
          background: "#000000",
          meteorHead: "rgba(255, 255, 255, 1)",
          meteorTailStart: "rgba(255, 255, 255, 1)",
          meteorTailMiddle: "rgba(255, 240, 200, 0.8)",
          meteorTailEnd: "rgba(255, 200, 100, 0.1)"
        }
      : {
          background: "#ffffff",
          meteorHead: "rgba(70, 90, 120, 1)",
          meteorTailStart: "rgba(70, 90, 120, 1)",
          meteorTailMiddle: "rgba(100, 120, 150, 0.8)",
          meteorTailEnd: "rgba(130, 150, 180, 0.1)"
        };

    const createMeteor = (): Meteor => {
      const positionFactor = Math.random();
      let x, y;

      if (positionFactor < 0.25) {
        x = -20;
        y = dimensions.height * Math.random() * 0.7;
      } else if (positionFactor < 0.5) {
        x = dimensions.width * Math.random();
        y = -20;
      } else if (positionFactor < 0.75) {
        x = dimensions.width + 20;
        y = dimensions.height * Math.random() * 0.7;
      } else {
        x = dimensions.width * 0.3 + Math.random() * dimensions.width * 0.4;
        y = -20;
      }

      const size = 1 + Math.random() * 10;
      const speed = 3;
      const angle = Math.PI / 4;
      const tailLength = 15;

      return {
        x,
        y,
        size,
        speed,
        angle,
        opacity: 0.7 + Math.random() * 0.3,
        tail: [],
        tailLength
      };
    };

    meteorsRef.current = Array.from({ length: 5 }, createMeteor);

    const animate = () => {
      ctx.fillStyle = themeColors.background;
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      meteorsRef.current.forEach((meteor, index) => {
        meteor.x += Math.cos(meteor.angle) * meteor.speed;
        meteor.y += Math.sin(meteor.angle) * meteor.speed;

        meteor.tail.unshift({ x: meteor.x, y: meteor.y });

        if (meteor.tail.length > meteor.tailLength) {
          meteor.tail.pop();
        }

        if (meteor.tail.length > 1) {
          ctx.beginPath();

          const gradient = ctx.createLinearGradient(
            meteor.tail[0].x,
            meteor.tail[0].y,
            meteor.tail[meteor.tail.length - 1].x,
            meteor.tail[meteor.tail.length - 1].y
          );

          gradient.addColorStop(0, themeColors.meteorTailStart.replace("1)", `${meteor.opacity})`));
          gradient.addColorStop(
            0.3,
            themeColors.meteorTailMiddle.replace("0.8)", `${meteor.opacity * 0.8})`)
          );
          gradient.addColorStop(
            1,
            themeColors.meteorTailEnd.replace("0.1)", `${meteor.opacity * 0.1})`)
          );

          ctx.strokeStyle = gradient;
          ctx.lineWidth = meteor.size;

          ctx.moveTo(meteor.tail[0].x, meteor.tail[0].y);
          for (let i = 1; i < meteor.tail.length; i++) {
            ctx.lineTo(meteor.tail[i].x, meteor.tail[i].y);
          }

          ctx.stroke();
        }

        ctx.beginPath();
        ctx.arc(meteor.x, meteor.y, meteor.size / 2, 0, Math.PI * 2);
        ctx.fillStyle = themeColors.meteorHead.replace("1)", `${meteor.opacity})`);
        ctx.fill();

        if (meteor.y > dimensions.height || meteor.x < -50 || meteor.x > dimensions.width + 50) {
          meteorsRef.current[index] = createMeteor();
        }
      });

      if (Math.random() < 0.02 && meteorsRef.current.length < 12) {
        meteorsRef.current.push(createMeteor());
      }

      animationRef.current = requestAnimationFrame(animate);
    };

    animate();

    return () => {
      cancelAnimationFrame(animationRef.current);
    };
  }, [dimensions, theme, resolvedTheme, mounted]);

  return (
    <div className={cn("relative w-full", className)}>
      <canvas
        ref={canvasRef}
        className="absolute h-full w-full"
        style={{ display: dimensions.width > 0 ? "block" : "none" }}
      />
      <div className="z-10">{children}</div>
    </div>
  );
}

Installation

npx shadcn@latest add @bundui/meteor-shower-animation

Usage

import { MeteorShowerAnimation } from "@/components/meteor-shower-animation"
<MeteorShowerAnimation />