bottega

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/meteors

Usage

Usagetsx
<Meteors />

Props

PropTypeDefaultDescription
countnumber20Meteors rendered simultaneously. Default 20. Keep <= 40 for perf.
anglenumber215Fall angle in degrees. Default 215 (top-right -> bottom-left).
minSpeednumber2Shortest animation duration in seconds. Default 2.
maxSpeednumber8Longest animation duration in seconds. Default 8. Wide spread avoids a synchronised wave.
minDelaynumber0Minimum initial animation-delay in seconds. Default 0.
maxDelaynumber6Maximum 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