Meteors
Thin streaks fall diagonally across a dark panel at random moments.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const POOL = 10;
// Resting spots, used for the first paint and as the static reduced motion view.
const START = Array.from({ length: POOL }, (_, i) => ({ left: `${18 + ((i * 37) % 90)}%`, top: `${((i * 23) % 50) - 10}%` }));
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const meteors = useRef<(HTMLSpanElement | null)[]>([]);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
// A pool of streaks: each launch grabs an idle one, places it and restarts its keyframes.
useEffect(() => {
if (!inView || reduced) return;
let timer = 0;
const launch = () => {
const el = meteors.current.find((m) => m && !m.classList.contains(styles.flying));
if (el) {
el.style.left = `${20 + Math.random() * 95}%`;
el.style.top = `${-15 + Math.random() * 45}%`;
el.style.animationDuration = `${1.2 + Math.random() * 1.4}s`;
el.classList.add(styles.flying);
}
timer = window.setTimeout(launch, 250 + Math.random() * 700);
};
launch();
return () => clearTimeout(timer);
}, [inView, reduced]);
return (
<div ref={root} className={styles.root}>
<div className={styles.sky} aria-hidden="true">
{START.map((pos, i) => (
<span
key={i}
ref={(el) => {
meteors.current[i] = el;
}}
className={styles.meteor}
style={pos}
onAnimationEnd={(e) => e.currentTarget.classList.remove(styles.flying)}
/>
))}
</div>
<div className={styles.copy}>
<b>Late arrival? Handled overnight.</b>
<span>Check-in details go out the moment a guest lands.</span>
</div>
</div>
);
}
demo.module.css
.root { container-type: size; position: relative; width: 100%; height: 100%; display: grid; place-items: center; padding: 20px; overflow: hidden; background: var(--ink); color: var(--white); }
.sky { position: absolute; inset: 0; pointer-events: none; }
/* The streak points down and to the left; its bright head is the right end. */
.meteor { position: absolute; width: 110px; height: 1px; opacity: 0; transform-origin: 100% 50%; rotate: 135deg; translate: -110px 0; background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent-soft) 70%, transparent) 60%, var(--white)); }
.meteor::after { content: ""; position: absolute; right: -1px; top: -1px; width: 3px; height: 3px; border-radius: 50%; background: var(--white); box-shadow: 0 0 6px 1px var(--accent-soft); }
.flying { animation: fall linear both; }
@keyframes fall {
0% { opacity: 0; transform: translateX(0); }
10% { opacity: 1; }
75% { opacity: 1; }
100% { opacity: 0; transform: translateX(min(560px, 140cqmax)); }
}
.copy { position: relative; display: grid; gap: 6px; max-width: 24em; text-align: center; }
.copy b { font-size: clamp(18px, 4cqi, 28px); letter-spacing: -0.02em; line-height: 1.15; text-wrap: balance; }
.copy span { font-size: 14px; color: color-mix(in srgb, var(--white) 65%, transparent); }
@media (prefers-reduced-motion: reduce) {
.meteor { opacity: .35; }
}
Use it for
- Dark feature panels or night themed sections
- Small accents around a single message
Avoid
- Light backgrounds, where the streaks read as scratches
- Large pages full of other motion
How it works
A fixed pool of ten streak elements is rendered once. A timer with a random interval takes an idle streak, gives it a random start position and duration, and adds a class that runs one keyframe; animationend frees it again. Each streak is rotated 135 degrees and travels along its own X axis, so the gradient head always leads. The timer only runs while the panel is on screen.
Accessibility
The streaks are aria-hidden decoration and the text sits on solid ink for contrast. Reduced motion stops launches and shows a few faint static streaks instead.