A canvas field of softly twinkling particles with depth, gentle drift and a pointer that attracts or repels them. Token colors, DPR aware, pauses off-screen, still under reduced motion.
Think in systems, not tickets.
One calm workspace for plans, docs and the decisions behind them.
Get early accessimport { ArrowRight } from "lucide-react"
import { Particles } from "@/components/ballmac/particles"
export default function ParticlesDemo() {
return (
<div className="relative isolate flex h-[360px] w-full max-w-2xl flex-col items-center justify-center overflow-hidden rounded-xl border bg-background px-6 text-center">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(ellipse_60%_50%_at_50%_100%,color-mix(in_oklch,var(--chart-1)_18%,transparent),transparent_70%)]"
/>
<Particles quantity={160} className="-z-10" />
<span className="inline-flex items-center gap-2 rounded-full border bg-background/60 px-3 py-1 text-xs font-medium text-muted-foreground backdrop-blur">
<span className="size-1.5 rounded-full bg-chart-1" aria-hidden="true" />
Now in public beta
</span>
<h2 className="mt-5 max-w-md bg-gradient-to-b from-foreground to-foreground/60 bg-clip-text text-4xl font-semibold tracking-tight text-balance text-transparent sm:text-5xl">
Think in systems, not tickets.
</h2>
<p className="mt-3 max-w-sm text-sm text-muted-foreground">
One calm workspace for plans, docs and the decisions behind them.
</p>
<a
href="#"
className="mt-6 inline-flex h-9 items-center gap-1.5 rounded-full 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"
>
Get early access
<ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" />
</a>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/particlesAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/colorCopy the source into your project.
components/ballmac/particles.tsx// Ballmac UI: Particles. https://ui.ballmac.com/components/particles "use client" import * as React from "react" import { cn } from "@/lib/utils" import { observeTheme, resolveCssColor } from "@/lib/ballmac/color" type ParticlesProps = Omit<React.ComponentProps<"div">, "children" | "color"> & { /** Number of particles. Scaled down on small containers so density stays even. */ quantity?: number /** Base particle radius in pixels; each particle varies around it. */ size?: number /** Particle color: a CSS variable name ("--foreground") or any CSS color. */ color?: string /** How the field reacts to the pointer. */ interaction?: "attract" | "repel" | "none" /** Radius of pointer influence in pixels. */ radius?: number /** Smoothing of the pointer response; higher is slower and softer. */ ease?: number /** Constant horizontal drift in pixels per frame. */ vx?: number /** Constant vertical drift in pixels per frame (negative rises). */ vy?: number /** Maximum particle opacity (0–1). */ maxOpacity?: number } type Particle = { x: number y: number dx: number dy: number r: number alpha: number target: number ox: number oy: number depth: number } function Particles({ quantity = 120, size = 0.7, color = "--foreground", interaction = "attract", radius = 140, ease = 24, vx = 0, vy = 0, maxOpacity = 0.7, className, ...props }: ParticlesProps) { const rootRef = React.useRef<HTMLDivElement>(null) const canvasRef = React.useRef<HTMLCanvasElement>(null) React.useEffect(() => { const root = rootRef.current const canvas = canvasRef.current const ctx = canvas?.getContext("2d") if (!root || !canvas || !ctx) return const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches let width = 0 let height = 0 let fill = resolveCssColor(root, color) let particles: Particle[] = [] const pointer = { x: -9999, y: -9999, inside: false } const spawn = (): Particle => { const depth = Math.random() return { x: Math.random() * width, y: Math.random() * height, dx: (Math.random() - 0.5) * 0.18 * (0.4 + depth), dy: (Math.random() - 0.5) * 0.18 * (0.4 + depth), r: size * (0.5 + depth * 1.3), alpha: 0, target: (0.25 + Math.random() * 0.75) * maxOpacity, ox: 0, oy: 0, depth, } } const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, 2) width = root.clientWidth height = root.clientHeight canvas.width = Math.round(width * dpr) canvas.height = Math.round(height * dpr) canvas.style.width = `${width}px` canvas.style.height = `${height}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // Density follows area: the given quantity is for a ~ 800×500 surface. const count = Math.max(12, Math.round((quantity * width * height) / (800 * 500))) particles = Array.from({ length: Math.min(count, quantity * 2) }, spawn) if (reduceMotion) for (const p of particles) p.alpha = p.target } const draw = () => { ctx.clearRect(0, 0, width, height) ctx.fillStyle = fill for (const p of particles) { if (!reduceMotion) { p.x += p.dx + vx * (0.5 + p.depth) p.y += p.dy + vy * (0.5 + p.depth) // Wrap around the edges so the field never empties. if (p.x < -10) p.x = width + 10 else if (p.x > width + 10) p.x = -10 if (p.y < -10) p.y = height + 10 else if (p.y > height + 10) p.y = -10 let tx = 0 let ty = 0 if (interaction !== "none" && pointer.inside) { const ddx = pointer.x - p.x const ddy = pointer.y - p.y const dist = Math.hypot(ddx, ddy) if (dist < radius && dist > 0.001) { const force = (1 - dist / radius) ** 2 * radius * 0.35 * (0.4 + p.depth) const sign = interaction === "attract" ? 1 : -1 tx = (ddx / dist) * force * sign ty = (ddy / dist) * force * sign } } p.ox += (tx - p.ox) / ease p.oy += (ty - p.oy) / ease // Slow twinkle: ease toward a target opacity, then pick a new one. p.alpha += (p.target - p.alpha) * 0.02 if (Math.abs(p.target - p.alpha) < 0.01) p.target = (0.2 + Math.random() * 0.8) * maxOpacity } const x = p.x + p.ox const y = p.y + p.oy // Fade particles near the edges. const edge = Math.min(x, y, width - x, height - y) const fade = Math.min(Math.max(edge / 40, 0), 1) ctx.globalAlpha = p.alpha * fade ctx.beginPath() ctx.arc(x, y, p.r, 0, Math.PI * 2) ctx.fill() } ctx.globalAlpha = 1 } let frame = 0 let visible = true const loop = () => { draw() frame = requestAnimationFrame(loop) } const sync = () => { cancelAnimationFrame(frame) if (reduceMotion) draw() else if (visible && !document.hidden) frame = requestAnimationFrame(loop) } const onPointerMove = (event: PointerEvent) => { const rect = root.getBoundingClientRect() pointer.x = event.clientX - rect.left pointer.y = event.clientY - rect.top pointer.inside = pointer.x >= 0 && pointer.y >= 0 && pointer.x <= rect.width && pointer.y <= rect.height } const onPointerLeave = () => { pointer.inside = false } resize() sync() const ro = new ResizeObserver(() => { resize() if (reduceMotion) draw() }) ro.observe(root) const io = new IntersectionObserver(([entry]) => { visible = entry?.isIntersecting ?? true sync() }) io.observe(root) const stopTheme = observeTheme(() => { fill = resolveCssColor(root, color) if (reduceMotion) draw() }) document.addEventListener("visibilitychange", sync) window.addEventListener("pointermove", onPointerMove, { passive: true }) document.documentElement.addEventListener("pointerleave", onPointerLeave) return () => { cancelAnimationFrame(frame) ro.disconnect() io.disconnect() stopTheme() document.removeEventListener("visibilitychange", sync) window.removeEventListener("pointermove", onPointerMove) document.documentElement.removeEventListener("pointerleave", onPointerLeave) } }, [quantity, size, color, interaction, radius, ease, vx, vy, maxOpacity]) return ( <div ref={rootRef} aria-hidden="true" data-slot="particles" className={cn("pointer-events-none absolute inset-0 overflow-hidden", className)} {...props} > <canvas ref={canvasRef} className="block" /> </div> ) } export { Particles, type ParticlesProps }Update the import paths to match your project setup.
Usage
import { Particles } from "@/components/ballmac/particles"The full example is in the Code tab above.
Examples
Rising, repel
Move your pointer
Signals, rising.
import { Particles } from "@/components/ballmac/particles"
export default function ParticlesRising() {
return (
<div className="relative isolate flex h-72 w-full max-w-xl flex-col items-center justify-center overflow-hidden rounded-xl border bg-card px-6 text-center">
<Particles quantity={200} color="--chart-1" interaction="repel" vy={-0.35} size={0.9} className="-z-10" />
<p className="font-mono text-xs tracking-widest text-muted-foreground uppercase">Move your pointer</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight">Signals, rising.</h3>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
quantityNumber of particles. Scaled down on small containers so density stays even. | number | 120 |
sizeBase particle radius in pixels; each particle varies around it. | number | 0.7 |
colorParticle color: a CSS variable name ("--foreground") or any CSS color. | string | "--foreground" |
interactionHow the field reacts to the pointer. | "attract" | "repel" | "none" | "attract" |
radiusRadius of pointer influence in pixels. | number | 140 |
easeSmoothing of the pointer response; higher is slower and softer. | number | 24 |
vxConstant horizontal drift in pixels per frame. | number | 0 |
vyConstant vertical drift in pixels per frame (negative rises). | number | 0 |
maxOpacityMaximum particle opacity (0–1). | number | 0.7 |
Also accepts the standard attributes of its root element.
Use with AI
Place <Particles /> as the first child of a relative, overflow-hidden container; it fills it (absolute inset-0, aria-hidden, pointer-events-none) and tracks the pointer at window level, so content above it stays clickable. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Particles (@ballmac/particles) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Quiet hero or CTA backgrounds, especially on dark surfaces
- Space, AI or data themed sections that need depth without imagery
Not for
- Behind long-form text
- Many instances on one page (each runs its own canvas loop)
Registry JSON: https://ui.ballmac.com/r/particles.json
Credits
Free to use in personal and commercial projects.
- npm
- None
- Registry
- @ballmac/colorshadcn/utils
Pairs well with
Text Reveal
Reveals a headline or paragraph word by word or character by character with a soft blur and rise, when it scrolls into view or on mount.
Shimmer Text
Text with a band of light sweeping across it, from muted to full foreground color. Suited to AI "thinking" and loading status lines; static under reduced motion.
Magnetic Button
The Ballmac Button with a magnetic pull: it drifts toward a nearby mouse pointer on a spring, and its label moves a little further for depth. Takes every Button prop.
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.