# Meteors (Moonarc)

Streaks falling diagonally across a twinkling sky. Each meteor is one element with a bright head, rotated to its angle and sent along its own axis; start, delay, length and pace come from a seeded generator on the server, so the same seed always draws the same sky. Stars are two tiled gradient layers blinking in alternation. Zero JS; fills its positioned parent.

- Import: `import Meteors from '@moonarc/core/Meteors'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/meteors.json`
- Tier A · category background · trigger always
- Readout: `<Meteors count={12} angle={135}>`
- Browser support: widely (Chrome 105 · Firefox 110 · Safari 16)
- Measured cost: 0 B JS (CSS 5.8 kB raw)
- Page: https://moonarc.dev/components/meteors/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `count` | `number` | `12` | Meteors in flight, up to 40. |
| `angle` | `number` | `135` | Direction of travel in CSS degrees: 0 right, 90 down, 135 down-left, 45 down-right. |
| `stars` | `boolean` | `true` | Draw the star field. |
| `duration` | `number` | `4` | Seconds per crossing; each meteor varies ±40 % around it. |
| `color` | `string` | `var(--ma-ink)` | Meteor and star colour. |
| `seed` | `number` | `3` | Seed for the generator; the same seed is the same sky. |

## Usage

```astro
<section class="relative overflow-hidden bg-black text-white">
  <Meteors count={16} />
  <h1 class="relative">Ship tonight</h1>
</section>
```

## Reduced motion

The stars stand still; the meteors are not shown.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Everything random is decided on the server with a seeded generator (mulberry32, the one GlyphField uses): the HTML is stable across builds, the visual test is reproducible, and the browser computes nothing.
- A meteor travels along its own rotated axis (rotate(angle) translateX(d) in one transform keyframe), so one property moves it and the head stays in front.
- Travel is measured in container units (cqmax): the meteor crosses the box whatever its size, with no px distance in the stylesheet.
- The stars are two layers of tiled radial gradients at coprime tile sizes so the pattern never visibly repeats; they blink in alternation by opacity, on the in-out curve, half a period apart.
- Linear timing on the streaks: a meteor does not ease.

## Replaces

- Meteors (Magic UI)
- Meteors (Aceternity)
- MeteorShower (Eldora)

## Source

```astro
---
/**
 * Meteors — streaks falling diagonally across a twinkling sky. Each meteor
 * is one element: a line with a bright head, rotated to `angle`, that
 * travels along its own axis and fades; its start, delay, length and pace
 * come from a seeded generator on the server, so the same seed always
 * draws the same sky (the visual test relies on it) and nothing is random
 * at runtime. The stars are two tiled gradient layers that blink in
 * alternation by opacity. Travel is measured in the box's own size
 * (container units), so it crosses any parent. Zero script.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Meteors in flight. */
  count?: number;
  /** Direction of travel in CSS degrees: 0 right, 90 down, 135 down-left, 45 down-right. */
  angle?: number;
  /** Draw the star field. */
  stars?: boolean;
  /** Seconds per crossing (each meteor varies ±40 %). */
  duration?: number;
  /** Meteor colour. */
  color?: string;
  /** Seed for the generator; the same seed is the same sky. */
  seed?: number;
}

const { count = 12, angle = 135, stars = true, duration = 4, color = 'var(--ma-ink)', seed = 3, class: className, style, ...rest } = Astro.props;
const n = Math.max(1, Math.min(40, Math.round(count)));
// mulberry32, the generator GlyphField uses
function mulberry32(a: number) {
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const rand = mulberry32(Math.floor(seed) || 1);
const r2 = (v: number) => Math.round(v * 100) / 100;
const meteors = Array.from({ length: n }, () => ({
  x: r2(rand() * 130 - 15),
  y: r2(rand() * 80 - 40),
  delay: r2(rand()),
  pace: r2(0.6 + rand() * 0.8),
  len: r2(0.5 + rand()),
}));
const inline = [`--ma-meteor-angle:${angle}deg`, `--ma-meteor-dur:${duration}s`, `--ma-meteor-color:${color}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-meteors', className]} aria-hidden="true" data-stars={stars ? '' : undefined} style={inline} {...rest}>
  {meteors.map((m) => <i style={`--ma-meteor-x:${m.x}%;--ma-meteor-y:${m.y}%;--ma-meteor-d:${m.delay};--ma-meteor-p:${m.pace};--ma-meteor-l:${m.len}`}></i>)}
</div>

<style is:global>
  @layer components {
    :where(.ma-meteors) {
      position: absolute;
      inset: 0;
      overflow: hidden;
      pointer-events: none;
      contain: strict;
      container-type: size;
      color: var(--ma-meteor-color, var(--ma-ink));
    }
    /* the sky: two layers of tiled star gradients that blink in alternation */
    :where(.ma-meteors[data-stars])::before,
    :where(.ma-meteors[data-stars])::after {
      content: '';
      position: absolute;
      inset: 0;
      background-image:
        radial-gradient(circle, currentColor 0.6px, transparent 1.1px),
        radial-gradient(circle, currentColor 0.5px, transparent 1px);
      background-size:
        173px 131px,
        97px 211px;
      background-position:
        23px 41px,
        61px 7px;
      opacity: 0.45;
      animation: ma-meteors-twinkle calc(var(--ma-meteor-dur) * 1.5) var(--ma-ease-in-out) infinite alternate;
    }
    :where(.ma-meteors[data-stars])::after {
      background-size:
        149px 167px,
        113px 89px;
      background-position:
        7px 83px,
        91px 29px;
      animation-delay: calc(var(--ma-meteor-dur) * -0.75);
    }
    /* a meteor: a fading line with a bright head at its front, laid along its direction (local x) and sent along its own axis */
    :where(.ma-meteors > i) {
      position: absolute;
      left: var(--ma-meteor-x, 50%);
      top: var(--ma-meteor-y, 0%);
      width: calc(var(--ma-meteor-l, 1) * 20cqmax);
      height: 1px;
      transform-origin: 100% 50%;
      background: linear-gradient(to right, transparent, currentColor);
      opacity: 0;
      will-change: transform, opacity;
      animation: ma-meteor calc(var(--ma-meteor-dur) * var(--ma-meteor-p, 1)) linear infinite;
      animation-delay: calc(var(--ma-meteor-dur) * var(--ma-meteor-d, 0) * -2);
    }
    :where(.ma-meteors > i)::before {
      content: '';
      position: absolute;
      left: 100%;
      top: 50%;
      width: 3px;
      height: 3px;
      translate: -50% -50%;
      border-radius: 50%;
      background: currentColor;
      box-shadow: 0 0 6px 1px currentColor;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-meteors[data-stars])::before,
      :where(.ma-meteors[data-stars])::after {
        animation: none;
      }
      :where(.ma-meteors > i) {
        display: none;
      }
    }
  }

  @keyframes ma-meteor {
    0% {
      transform: rotate(var(--ma-meteor-angle, 135deg)) translateX(0);
      opacity: 0;
    }
    8% {
      opacity: 1;
    }
    70% {
      opacity: 1;
    }
    100% {
      transform: rotate(var(--ma-meteor-angle, 135deg)) translateX(140cqmax);
      opacity: 0;
    }
  }
  @keyframes ma-meteors-twinkle {
    from {
      opacity: 0.2;
    }
    to {
      opacity: 0.6;
    }
  }
</style>

```
