Skip to content
UI Kit
Accent color

Meteors

Thin streaks that cross the stage on a slant, from seeded positions over a faint field of stars.

One of 6 components in Beams, particles and motion

3 variants

Variants

Shower

Many streaks with an accent head, for a launch night.

Calm sky

A few long, slow streaks in the text color, the stars drift like a far sky while you scroll.

Steep

Streaks fall almost straight down, the angle is free.

Details

What Meteors brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/meteors.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The MeteorsProps interface from components/ui/meteors.tsx, with the comments from the code.

  • countoptional
    number

    Number of streaks, default 12

  • angleoptional
    number

    Direction of travel in degrees on screen, 0 is to the right, 90 straight down. Default 135 (down to the left).

  • speedoptional
    number

    Average seconds between two passes of one streak, default 10

  • lengthoptional
    number

    Tail length in px, default 140

  • toneoptional
    'accent' | 'neutral'

    accent tints the head, neutral stays in the text color

  • starsoptional
    number

    Faint fixed stars behind the streaks, default 40

  • seedoptional
    number

    Same seed, same sky

  • parallaxoptional
    number

    Pixels the field of stars drifts against the page over a full scroll pass, like a far sky. Default 0 (off), 40 reads as depth.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 158 lines
components/ui/meteors.tsx
'use client';

import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, round } from '@/lib/kit';

export interface MeteorsProps {
  /** Number of streaks, default 12 */
  count?: number;
  /** Direction of travel in degrees on screen, 0 is to the right, 90 straight down. Default 135 (down to the left). */
  angle?: number;
  /** Average seconds between two passes of one streak, default 10 */
  speed?: number;
  /** Tail length in px, default 140 */
  length?: number;
  /** accent tints the head, neutral stays in the text color */
  tone?: 'accent' | 'neutral';
  /** Faint fixed stars behind the streaks, default 40 */
  stars?: number;
  /** Same seed, same sky */
  seed?: number;
  /** Pixels the field of stars drifts against the page over a full scroll pass, like a far sky. Default 0 (off), 40 reads as depth. */
  parallax?: number;
  className?: string;
}

const CSS = `
@keyframes kitfx-meteor {
  0% { transform: translate3d(0, 0, 0); opacity: 0; }
  4% { opacity: 1; }
  32% { opacity: 1; }
  40%, 100% { transform: translate3d(var(--kitfx-d), 0, 0); opacity: 0; }
}
.kitfx-meteors * { animation-play-state: var(--kitfx-play, running); }
@media (prefers-reduced-motion: reduce) {
  .kitfx-meteors-sky { transform: none !important; }
  .kitfx-meteors [data-meteor] { animation: none !important; transform: translate3d(var(--kitfx-still), 0, 0); opacity: var(--kitfx-still-o); }
}
`;

function seeded(seed: number) {
  let a = seed >>> 0 || 1;
  return () => {
    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;
  };
}

/**
 * Scroll pass. While `on`, writes --kitfx-p on the element: 0 when its top
 * meets the bottom of the window, 1 when its bottom leaves at the top. Never
 * with reduced motion, the CSS then falls back to the resting value.
 */
function useScrollPass(ref: { current: HTMLElement | null }, on: boolean) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on]);
}

/**
 * Thin streaks that cross the stage on a slant, from seeded positions so
 * server and browser agree. Fills its parent, decorative. With reduced
 * motion a few streaks stand still mid flight, the rest stay hidden.
 */
export function Meteors({ count = 12, angle = 135, speed = 10, length = 140, tone = 'accent', stars = 40, seed = 5, parallax = 0, className }: MeteorsProps) {
  const ref = useRef<HTMLDivElement>(null);
  useScrollPass(ref, parallax !== 0);
  useEffect(() => {
    const el = ref.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const rnd = seeded(seed);
  const rad = (angle * Math.PI) / 180;
  // Start on the edges the streaks come from, spread so they cross the whole stage
  const fromRight = Math.cos(rad) < 0;
  const meteors = Array.from({ length: count }, (_, i) => {
    const along = rnd();
    const onTop = rnd() < 0.72;
    const x = onTop ? round(-10 + along * 120) : fromRight ? 104 : -4;
    const y = onTop ? -4 : round(along * 70);
    const dur = round(speed * (0.7 + rnd() * 0.7), 1);
    return {
      x,
      y,
      dur,
      delay: round(-rnd() * dur, 1),
      dist: Math.round(700 + rnd() * 600),
      len: Math.round(length * (0.6 + rnd() * 0.7)),
      show: i % 3 === 0,
      still: round(0.12 + rnd() * 0.2),
    };
  });
  const sky = Array.from({ length: stars }, () => ({ x: round(rnd() * 100), y: round(rnd() * 100), s: rnd() > 0.85 ? 2 : 1, o: round(0.25 + rnd() * 0.6) }));
  const head = tone === 'accent' ? 'color-mix(in oklab, var(--primary) 80%, var(--foreground))' : 'var(--foreground)';

  return (
    <div ref={ref} aria-hidden data-clip-ok className={cn('kitfx-meteors pointer-events-none absolute inset-0 overflow-hidden', className)}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <div className="kitfx-meteors-sky absolute inset-0" style={parallax ? { transform: `translate3d(0, calc((var(--kitfx-p, 0.5) - 0.5) * ${parallax}px), 0)` } : undefined}>
        {sky.map((s, i) => (
          <span
            key={`s${i}`}
            className="absolute rounded-full bg-foreground"
            style={{ left: `${s.x}%`, top: `${s.y}%`, width: s.s, height: s.s, opacity: round(s.o * 0.3) }}
          />
        ))}
      </div>
      {meteors.map((m, i) => (
        <div key={i} className="absolute" style={{ left: `${m.x}%`, top: `${m.y}%`, transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }}>
          <div
            data-meteor=""
            className="relative h-px opacity-0"
            style={{
              width: m.len,
              marginLeft: -m.len,
              ['--kitfx-d' as string]: `${m.dist}px`,
              ['--kitfx-still' as string]: `${Math.round(m.dist * m.still)}px`,
              ['--kitfx-still-o' as string]: m.show ? 0.8 : 0,
              animation: `kitfx-meteor ${m.dur}s linear ${m.delay}s infinite`,
            }}
          >
            {/* tail fades out behind the head, the head carries a small glow */}
            <span className="absolute inset-0 rounded-full opacity-60 dark:opacity-80" style={{ background: `linear-gradient(to right, transparent, color-mix(in oklch, ${head} 70%, transparent))` }} />
            <span
              className="absolute right-0 top-1/2 size-[3px] -translate-y-1/2 rounded-full"
              style={{ background: head, boxShadow: `0 0 0 1px color-mix(in oklch, ${head} 18%, transparent), 0 0 10px 2px color-mix(in oklch, ${head} 40%, transparent)` }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}

Frequent questions

How do I install Meteors?

With aiw add @ai-website/meteors. The command puts components/ui/meteors.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Meteors have?

3 variants, Shower, Calm sky and Steep.

Can an AI assistant build with Meteors?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Meteors with aiw_components and install it with aiw_add @ai-website/meteors.

Often searched

Jump to

Copied to the clipboard