Meteor Shower Animation
The Meteor Shower Animation background adds a stunning layer of motion and elegance to your UI using canvas-based animation. Inspired by natural meteor showers, each meteor streaks across the screen in a soft, diagonal flow with customizable speed, color, and density. The animation is optimized for performance and reacts responsively to different screen sizes. It's ideal for landing pages, hero sections, or any dark-themed website where subtle, dynamic ambiance enhances the user experience without distraction.
Bundui Components
Installation
Install the following dependencies:
npm i next-themes clsx tailwind-merge
Add utils file
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the following code into your project:
"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 } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch
useEffect(() => {
setMounted(true);
}, []);
// Initialize canvas dimensions
useEffect(() => {
const updateDimensions = () => {
if (typeof window !== "undefined") {
setDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => {
window.removeEventListener("resize", updateDimensions);
};
}, []);
// Main animation logic
useEffect(() => {
if (
!canvasRef.current ||
dimensions.width === 0 ||
dimensions.height === 0 ||
!mounted
)
return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Set canvas dimensions
canvas.width = dimensions.width;
canvas.height = dimensions.height;
// Define theme colors
const themeColors: ThemeColors =
theme === "dark"
? {
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: "#f0f8ff", // Light blue sky
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)",
};
// Create meteors with varied starting positions
const createMeteor = (): Meteor => {
// Determine a random starting position across the screen
// We'll use different edges and positions for variety
// Random position factor (0-1)
const positionFactor = Math.random();
// Choose a starting position based on screen dimensions
let x, y;
// Start from left side of screen
if (positionFactor < 0.25) {
x = -20;
y = dimensions.height * Math.random() * 0.7; // Random position on left edge
}
// Start from top of screen
else if (positionFactor < 0.5) {
x = dimensions.width * Math.random();
y = -20; // Just above the top edge
}
// Start from right side of screen
else if (positionFactor < 0.75) {
x = dimensions.width + 20;
y = dimensions.height * Math.random() * 0.7; // Random position on right edge
}
// Start from middle-top area
else {
x = dimensions.width * 0.3 + Math.random() * dimensions.width * 0.4; // Middle 40% of screen width
y = -20;
}
// Random size between 1 and 3
const size = 1 + Math.random() * 2;
// Medium-slow speed (consistent for all meteors)
const speed = 2.5;
// Fixed angle for consistent direction (45 degrees in radians)
const angle = Math.PI / 4;
// Fixed tail length
const tailLength = 15;
return {
x,
y,
size,
speed,
angle,
opacity: 0.7 + Math.random() * 0.3,
tail: [],
tailLength,
};
};
// Initialize meteors
meteorsRef.current = Array.from({ length: 5 }, createMeteor);
// Animation loop
const animate = () => {
// Fill with theme background
ctx.fillStyle = themeColors.background;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Update and draw meteors
meteorsRef.current.forEach((meteor, index) => {
// Update position
meteor.x += Math.cos(meteor.angle) * meteor.speed;
meteor.y += Math.sin(meteor.angle) * meteor.speed;
// Add current position to tail
meteor.tail.unshift({ x: meteor.x, y: meteor.y });
// Limit tail length
if (meteor.tail.length > meteor.tailLength) {
meteor.tail.pop();
}
// Draw meteor tail (gradient line)
if (meteor.tail.length > 1) {
ctx.beginPath();
// Create gradient for tail
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;
// Draw the tail path
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();
}
// Draw meteor head (small circle)
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();
// Reset meteor if it goes off screen
if (
meteor.y > dimensions.height ||
meteor.x < -50 ||
meteor.x > dimensions.width + 50
) {
meteorsRef.current[index] = createMeteor();
}
});
// Add new meteors randomly
if (Math.random() < 0.02 && meteorsRef.current.length < 12) {
meteorsRef.current.push(createMeteor());
}
animationRef.current = requestAnimationFrame(animate);
};
animate();
// Cleanup
return () => {
cancelAnimationFrame(animationRef.current);
};
}, [dimensions, theme, mounted]);
return (
<div className={cn("relative w-full", className)}>
<canvas
ref={canvasRef}
className="absolute w-full h-full"
style={{ display: dimensions.width > 0 ? "block" : "none" }}
/>
<div className="z-10">{children}</div>
</div>
);
}
API
Prop | Type | Default | Description |
---|---|---|---|
children | React.ReactNode | The content to be displayed on top of the meteor shower animation. | |
className | string | undefined | Optional Tailwind/CSS classes for custom styling on the outer container. |