Domphy

meteors

A Effects block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call meteors() with no arguments for a working demo, or edit the code below live.

Implementation notes

Fully functional pure-CSS port matching the spec's defaults (count 20, angle 215deg, delay range 0.2-1.2s, duration range 2-10s). One accepted CSS-only tradeoff, explicitly allowed by the spec itself ("No JS animation loop is required"): because a single shared @keyframes rule drives every meteor's infinite loop, each meteor replays the exact same left-offset/path every cycle — only the initial animation-delay/animation-duration are randomized once at generation time, not a fresh random position every loop iteration (that would require a JS-driven rAF respawn loop instead of pure CSS). Travel distance uses vmax (viewport-relative) rather than a fixed px/em length so one shared keyframe reliably clears any container size.

Status: ported · Reference: Magic UI original

// Magic UI "Meteors" — clean-room reimplementation.
//
// A decorative background effect that continuously spawns thin glowing
// streaks ("meteors") shooting diagonally across a bounded, overflow-hidden
// container, like a meteor shower behind hero text or cards. Implemented
// purely from the block's public functional/visual spec — no upstream Magic
// UI source was viewed or copied.
//
// Pure CSS: a single shared `@keyframes` rule animates every meteor's
// position/opacity along the configured trajectory angle; each meteor gets
// its own randomized `animation-delay`/`animation-duration` (computed once at
// generation time) so the shared keyframe plays out staggered per element.
// No JS animation loop is required. Because the animation loops infinitely,
// each meteor replays the same path every cycle — only its initial timing is
// randomized, not a fresh position per loop (a JS-driven per-cycle respawn
// would need a rAF loop instead of pure CSS; see the component's
// `fidelityNotes` for this tradeoff).

import type { DomphyElement, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export interface MeteorsProps {
  /** Number of meteors rendered. Defaults to `20`. */
  count?: number;
  /** Minimum randomized start delay, in seconds. Defaults to `0.2`. */
  minDelay?: number;
  /** Maximum randomized start delay, in seconds. Defaults to `1.2`. */
  maxDelay?: number;
  /** Minimum randomized fall duration, in seconds. Defaults to `2`. */
  minDuration?: number;
  /** Maximum randomized fall duration, in seconds. Defaults to `10`. */
  maxDuration?: number;
  /** Trajectory angle in degrees (215 = down-and-to-the-left). Defaults to `215`. */
  angle?: number;
  /** Theme color family for the meteor head/tail glow. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Foreground content layered above the shower. Defaults to a small demo heading. */
  children?: DomphyElement | DomphyElement[];
  /** Passthrough style merged onto the outer container. */
  style?: StyleObject;
}

let meteorsInstanceCounter = 0;

/**
 * A layered field of continuously falling, staggered "meteor" streaks behind
 * foreground content — a decorative CSS-only meteor shower. Call with no
 * arguments for a working demo — a dark panel with 20 staggered meteors
 * behind a heading.
 */
function meteors(props: MeteorsProps = {}): DomphyElement<"div"> {
  const instanceId = ++meteorsInstanceCounter;
  const count = Math.max(1, Math.round(props.count ?? 20));
  const minDelay = props.minDelay ?? 0.2;
  const maxDelay = props.maxDelay ?? 1.2;
  const minDuration = props.minDuration ?? 2;
  const maxDuration = props.maxDuration ?? 10;
  const angle = props.angle ?? 215;
  const color = props.color ?? "neutral";

  // Travel distance is expressed in `vmax` (viewport-relative, not a literal
  // rem/em/px length) so a single shared keyframe reliably carries every
  // meteor off any container's edge regardless of its size.
  const keyframes = {
    "0%": { transform: `rotate(${angle}deg) translateX(0)`, opacity: 1 },
    "70%": { opacity: 1 },
    "100%": { transform: `rotate(${angle}deg) translateX(-100vmax)`, opacity: 0 },
  };
  const animationName = `meteor-fall-${hashString(JSON.stringify({ keyframes, instanceId }))}`;

  const meteorElements: DomphyElement[] = Array.from({ length: count }, (_unused, index) => {
    const leftPercent = Math.random() * 100;
    const delaySeconds = minDelay + Math.random() * Math.max(0, maxDelay - minDelay);
    const durationSeconds = minDuration + Math.random() * Math.max(0, maxDuration - minDuration);

    return {
      span: null,
      _key: `meteor-${instanceId}-${index}`,
      ariaHidden: "true",
      // Decorative streak with no text of its own — exempt from the
      // missing-color contract (mirrors fadeOverlay() in the marquee block).
      // Also exempt from tone-background-inherit: a meteor's glow is
      // intentionally a fixed bright accent, not a surface that should track
      // the ambient dataTone context.
      _doctorDisable: ["missing-color", "tone-background-inherit"],
      style: {
        position: "absolute",
        top: 0,
        left: `${leftPercent}%`,
        width: themeSpacing(0.5),
        height: themeSpacing(0.5),
        borderRadius: "50%",
        // shift-11/-9 (not a small shift-1/-2) so the head/tail read as a
        // bright glow against the dark shift-15 container surface — a small
        // shift only nudges toward the opposite edge by a couple of ramp
        // steps and would barely be distinguishable from the background.
        backgroundColor: (listener) => themeColor(listener, "shift-11", color),
        boxShadow: (listener) =>
          `0 0 ${themeSpacing(2)} ${themeColor(listener, "shift-9", color)}`,
        animation: `${animationName} ${durationSeconds}s linear ${delaySeconds}s infinite`,
        "&::before": {
          content: `""`,
          position: "absolute",
          top: "50%",
          right: 0,
          width: themeSpacing(14),
          height: themeSpacing(0.25),
          transform: "translateY(-50%)",
          background: (listener) =>
            `linear-gradient(to left, ${themeColor(listener, "shift-11", color)}, transparent)`,
        },
      } as StyleObject,
    } as DomphyElement;
  });

  const defaultChildren: DomphyElement[] = [
    { h2: "Meteor Shower", $: [heading()] } as DomphyElement,
    {
      p: "A layered field of streaking meteors behind your content.",
      $: [paragraph()],
    } as DomphyElement,
  ];
  const contentChildren = props.children
    ? Array.isArray(props.children)
      ? props.children
      : [props.children]
    : defaultChildren;

  return {
    div: [
      ...meteorElements,
      { div: contentChildren, style: { position: "relative", zIndex: 1 } },
    ],
    dataTone: "shift-15",
    style: {
      position: "relative",
      overflow: "hidden",
      borderRadius: themeSpacing(4),
      padding: themeSpacing(8),
      minHeight: themeSpacing(64),
      backgroundColor: (listener) => themeColor(listener, "inherit"),
      color: (listener) => themeColor(listener, "shift-9"),
      [`@keyframes ${animationName}`]: keyframes,
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { meteors };

← Back to Magic UI catalog