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
Details
What Meteors brings along, read from the registry and the source.
- Category
- Beams, particles and motion
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The MeteorsProps interface from components/ui/meteors.tsx, with the comments from the code.
countoptionalnumberNumber of streaks, default 12
angleoptionalnumberDirection of travel in degrees on screen, 0 is to the right, 90 straight down. Default 135 (down to the left).
speedoptionalnumberAverage seconds between two passes of one streak, default 10
lengthoptionalnumberTail length in px, default 140
toneoptional'accent' | 'neutral'accent tints the head, neutral stays in the text color
starsoptionalnumberFaint fixed stars behind the streaks, default 40
seedoptionalnumberSame seed, same sky
parallaxoptionalnumberPixels 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
'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.