backgrounds
Meteors
A diagonal meteor-shower background layer. Each meteor is a <span> with a CSS ::before gradient tail (--foreground) and a warm radial glow head (--primary). Per-meteor luminance (0.55-1.0) and varied tail length create a depth illusion rather than flat uniform rain. Geometry is randomised post-mount for hydration safety; an IntersectionObserver pauses the shower offscreen to save GPU. Decorative by default (role=presentation, aria-hidden). Place sibling content at z-10 above the z-0 shower.
meteors
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/meteorsUsage
Usagetsx
<Meteors />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| count | number | 20 | Meteors rendered simultaneously. Default 20. Keep <= 40 for perf. |
| angle | number | 215 | Fall angle in degrees. Default 215 (top-right -> bottom-left). |
| minSpeed | number | 2 | Shortest animation duration in seconds. Default 2. |
| maxSpeed | number | 8 | Longest animation duration in seconds. Default 8. Wide spread avoids a synchronised wave. |
| minDelay | number | 0 | Minimum initial animation-delay in seconds. Default 0. |
| maxDelay | number | 6 | Maximum initial animation-delay in seconds. Default 6. Staggers cold-start. |
Source
meteors.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import type React from "react";
import styles from "./meteors.module.css";
export interface MeteorsProps extends React.HTMLAttributes<HTMLDivElement> {
/** Meteors rendered simultaneously. Default 20. Keep <= 40 for perf. */
count?: number;
/** Fall angle in degrees. Default 215 (top-right -> bottom-left). */
angle?: number;
/** Shortest animation duration in seconds. Default 2. */
minSpeed?: number;
/** Longest animation duration in seconds. Default 8. Wide spread avoids a synchronised wave. */
maxSpeed?: number;
/** Minimum initial animation-delay in seconds. Default 0. */
minDelay?: number;
/** Maximum initial animation-delay in seconds. Default 6. Staggers cold-start. */
maxDelay?: number;
}
interface Meteor {
left: string;
duration: string;
delay: string;
opacity: number;
length: string;
}
/**
* Diagonal meteor-shower background layer (client component).
*
* Each meteor is a <span> with a ::before gradient tail (--foreground) and a
* warm radial glow head (--primary). Per-meteor luminance (0.55-1.0) and tail
* length (60-160px) create a depth illusion. Geometry is randomised after mount
* to stay hydration-safe; the IntersectionObserver pauses the layer offscreen.
*
* Decorative: root carries role="presentation" + aria-hidden by default. Place
* sibling content at z-10 above the z-0 shower. Reduced-motion users get a
* static three-line CSS fallback (no JS meteors) for spatial depth without motion.
*/
export function Meteors({
count = 20,
angle = 215,
minSpeed = 2,
maxSpeed = 8,
minDelay = 0,
maxDelay = 6,
className = "",
style,
"aria-hidden": ariaHidden = true,
...props
}: MeteorsProps) {
// Contract mount-gate + null-safe reduce check (avoids React #418 + flash).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
const layerRef = useRef<HTMLDivElement>(null);
const [meteors, setMeteors] = useState<Meteor[]>([]);
const [running, setRunning] = useState(true);
// Generate randomised geometry only after mount (hydration-safe).
useEffect(() => {
if (!animate) {
setMeteors([]);
return;
}
const next: Meteor[] = Array.from({ length: count }, (_, i) => {
// x-range -20% .. 120% so meteors enter/exit cleanly without clustering.
const left = Math.random() * 140 - 20;
const duration = Math.random() * (maxSpeed - minSpeed) + minSpeed;
// Half the meteors get a negative delay (skip ahead) so frame 0 is populated.
const baseDelay = Math.random() * (maxDelay - minDelay) + minDelay;
const delay = i % 2 === 0 ? -baseDelay : baseDelay;
// Luminance peak band 0.55-1.0 -> z-depth illusion (dim = far, bright = near).
const opacity = Math.random() * 0.45 + 0.55;
// Tail length 60-160px so short streaks coexist with long dramatic ones.
const length = Math.round(Math.random() * 100 + 60);
return {
left: `${left}%`,
duration: `${duration}s`,
delay: `${delay}s`,
opacity,
length: `${length}px`,
};
});
setMeteors(next);
}, [animate, count, minSpeed, maxSpeed, minDelay, maxDelay]);
// Pause the shower when scrolled fully out of view to save GPU.
useEffect(() => {
if (!animate) return;
const el = layerRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setRunning(entry.isIntersecting),
{ rootMargin: "0px" },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
// Travel along the rotated meteor axis must carry meteors DOWNWARD (positive
// screen Y) for ANY `angle` prop. Screen-Y displacement = travel * sin(angle),
// so pick the travel sign to keep that positive; 120vmax clears the viewport.
const meteorTravel = `${Math.sin((angle * Math.PI) / 180) > 0 ? 120 : -120}vmax`;
return (
<div
role="presentation"
aria-hidden={ariaHidden}
className={[styles.root, "bg-background", className].filter(Boolean).join(" ")}
style={style}
{...props}
>
{/* Animated layer: only present for non-reduced, mounted clients. */}
{animate && (
<div ref={layerRef} className={styles.layer}>
{meteors.map((m, i) => (
<span
key={i}
className={styles.meteor}
style={
{
"--meteor-left": m.left,
"--meteor-duration": m.duration,
"--meteor-delay": m.delay,
"--meteor-opacity": m.opacity,
"--meteor-length": m.length,
"--meteor-angle": `${angle}deg`,
// Sign computed in JS (see meteorTravel) so meteors fall
// downward for any `angle` prop, not just the 215 default.
"--meteor-travel": meteorTravel,
animationPlayState: running ? "running" : "paused",
} as React.CSSProperties
}
/>
))}
</div>
)}
{/* Static fallback: always in the DOM and visible at SSR/first paint
(mounted=false) so the base never renders blank. Hidden only once the
animated meteor layer takes over (non-reduced, mounted clients). For
reduced-motion users (incl. useReducedMotion()===null) it stays
visible. Driving this from JS state, not the CSS media query alone,
also covers reduced preferences detected only in JS. */}
<div
className={[
styles.staticFallback,
animate ? styles.staticFallbackHidden : "",
]
.filter(Boolean)
.join(" ")}
aria-hidden="true"
>
<span className={styles.staticLine} />
<span className={styles.staticLine} />
<span className={styles.staticLine} />
</div>
</div>
);
}
Dependencies
- motion
- @bottega/tokens