Thin meteors with glowing heads and fading tails streak across a container at a set angle. Randomized after mount (hydration-safe), paused off-screen, hidden under reduced motion.
A faster way to think.
The biggest update since launch, rebuilt from the storage layer up.
- Offline sync across every device
- Command palette with natural search
- Up to 3× faster cold starts
import { Check } from "lucide-react"
import { Meteors } from "@/components/ballmac/meteors"
const notes = ["Offline sync across every device", "Command palette with natural search", "Up to 3× faster cold starts"]
export default function MeteorsDemo() {
return (
<div className="relative isolate w-full max-w-md overflow-hidden rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
<div
aria-hidden="true"
className="pointer-events-none absolute -top-32 -end-32 -z-10 size-80 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--chart-1)_18%,transparent),transparent)]"
/>
<Meteors className="-z-10" />
<span className="font-mono text-xs text-muted-foreground">v2.0 · released today</span>
<h3 className="mt-3 text-2xl font-semibold tracking-tight">A faster way to think.</h3>
<p className="mt-1.5 text-sm text-muted-foreground">
The biggest update since launch, rebuilt from the storage layer up.
</p>
<ul className="mt-5 space-y-2.5">
{notes.map((note) => (
<li key={note} className="flex items-center gap-2.5 text-sm">
<span className="flex size-5 items-center justify-center rounded-full border bg-background">
<Check className="size-3" aria-hidden="true" />
</span>
{note}
</li>
))}
</ul>
<a
href="#"
className="mt-6 inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Read the release notes
</a>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/meteorsCopy the source into your project.
components/ballmac/meteors.tsx// Ballmac UI: Meteors. https://ui.ballmac.com/components/meteors "use client" import * as React from "react" import { cn } from "@/lib/utils" type MeteorsProps = Omit<React.ComponentProps<"div">, "children" | "color"> & { /** Number of meteors in flight at once. */ count?: number /** Direction of travel in degrees: 0 is right, 90 is down, 135 is down-left. */ angle?: number /** Shortest and longest seconds for one meteor to cross. */ duration?: [number, number] /** Shortest and longest tail length in pixels. */ tail?: [number, number] /** Meteor color. Any CSS color or var(). */ color?: string } type Meteor = { id: number; left: number; top: number; delay: number; duration: number; tail: number } function Meteors({ count = 14, angle = 135, duration = [2.4, 5.5], tail = [60, 160], color = "color-mix(in oklch, var(--foreground) 70%, transparent)", className, style, ...props }: MeteorsProps) { const rootRef = React.useRef<HTMLDivElement>(null) // Positions are random, so they are created on the client after mount; the server renders an empty layer. const [meteors, setMeteors] = React.useState<Meteor[]>([]) const [distance, setDistance] = React.useState(0) const [minDuration, maxDuration] = duration const [minTail, maxTail] = tail React.useEffect(() => { const root = rootRef.current if (!root || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return const between = (a: number, b: number) => a + Math.random() * (b - a) const rad = (angle * Math.PI) / 180 const goingLeft = Math.cos(rad) < 0 setMeteors( Array.from({ length: count }, (_, id) => { const d = between(minDuration, maxDuration) return { id, // Start along the top edge, spread past the side the meteors travel away from. left: goingLeft ? between(10, 130) : between(-30, 90), top: between(-20, 30), delay: between(0, maxDuration * 2), duration: d, tail: between(minTail, maxTail), } }) ) const measure = () => setDistance(Math.hypot(root.clientWidth, root.clientHeight) * 1.1) measure() const ro = new ResizeObserver(measure) ro.observe(root) return () => ro.disconnect() }, [count, angle, minDuration, maxDuration, minTail, maxTail]) // Web Animations keep this free of global CSS and let us pause off-screen. React.useEffect(() => { const root = rootRef.current if (!root || !distance || !meteors.length) return const nodes = root.querySelectorAll<HTMLElement>("[data-slot=meteor]") const animations = Array.from(nodes).map((node, i) => { const m = meteors[i]! // Each loop is the flight plus a pause, so meteors don't fire back to back. const flight = m.duration * 1000 const total = flight * 1.8 return node.animate( [ { transform: `rotate(${angle}deg) translateX(0px)`, opacity: 0, offset: 0 }, { opacity: 1, offset: 0.06 }, { opacity: 1, offset: (flight / total) * 0.7 }, { transform: `rotate(${angle}deg) translateX(${distance}px)`, opacity: 0, offset: flight / total }, { transform: `rotate(${angle}deg) translateX(${distance}px)`, opacity: 0, offset: 1 }, ], { duration: total, delay: m.delay * 1000, iterations: Infinity, easing: "linear", fill: "backwards" } ) }) let visible = true const sync = () => { for (const a of animations) { if (visible && !document.hidden) a.play() else a.pause() } } const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true sync() }) io.observe(root) document.addEventListener("visibilitychange", sync) return () => { io.disconnect() document.removeEventListener("visibilitychange", sync) for (const a of animations) a.cancel() } }, [meteors, distance, angle]) return ( <div ref={rootRef} aria-hidden="true" data-slot="meteors" className={cn("pointer-events-none absolute inset-0 overflow-hidden motion-reduce:hidden", className)} style={{ ...style, ["--meteor" as string]: color }} {...props} > {meteors.map((m) => ( <span key={m.id} data-slot="meteor" className="absolute size-[3px] rounded-full bg-(--meteor) opacity-0 shadow-[0_0_0_1px_color-mix(in_oklch,var(--meteor)_12%,transparent),0_0_10px_2px_color-mix(in_oklch,var(--meteor)_35%,transparent)]" style={{ left: `${m.left}%`, top: `${m.top}%`, transform: `rotate(${angle}deg)` }} > <span className="absolute top-1/2 right-1/2 h-px -translate-y-1/2 bg-linear-to-r from-transparent to-(--meteor)" style={{ width: m.tail }} /> </span> ))} </div> ) } export { Meteors, type MeteorsProps }Update the import paths to match your project setup.
Usage
import { Meteors } from "@/components/ballmac/meteors"The full example is in the Code tab above.
Examples
Steep, colored
Make a wish.
import { Meteors } from "@/components/ballmac/meteors"
export default function MeteorsColored() {
return (
<div className="relative isolate flex h-64 w-full max-w-xl items-center justify-center overflow-hidden rounded-xl border bg-background">
<Meteors count={22} angle={110} color="var(--chart-1)" duration={[1.6, 3.6]} className="-z-10" />
<p className="text-3xl font-semibold tracking-tight">Make a wish.</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
countNumber of meteors in flight at once. | number | 14 |
angleDirection of travel in degrees: 0 is right, 90 is down, 135 is down-left. | number | 135 |
durationShortest and longest seconds for one meteor to cross. | [number, number] | [2.4, 5.5] |
tailShortest and longest tail length in pixels. | [number, number] | [60, 160] |
colorMeteor color. Any CSS color or var(). | string | "color-mix(in oklch, var(--foreground) 70%, transparent)" |
Also accepts the standard attributes of its root element.
Use with AI
Put <Meteors /> inside a relative, overflow-hidden card or section (it is absolute inset-0, aria-hidden, pointer-events-none) and lift content with relative z-10. Meteor positions are generated on the client after mount, so the server renders an empty layer. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Meteors (@ballmac/meteors) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A launch, release or milestone card that should feel special
- Dark hero sections or CTA panels that need a hint of motion
Not for
- Dense UI or data tables
- Communicating anything; it is decorative and hidden under reduced motion
Registry JSON: https://ui.ballmac.com/r/meteors.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- shadcn/utils
Pairs well with
Spotlight Card
A card whose surface and border pick up a soft glow that follows the pointer, driven by CSS variables with no re-render per move. Keyboard focus shows a steady glow.
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Aurora Background
Soft, slowly drifting aurora light made of blurred token-colored glows with fine vertical curtains and an optional radial fade. Pure CSS layers, paused off-screen, still under reduced motion.