Concentric rings that breathe outward as a background, plus a press ripple that spreads from the pointer (or the center for keyboard) on any surface.
Listening for events
Rings breathe quietly behind the content.
import { Ripple } from "@/components/ballmac/ripple"
export default function RippleDemo() {
return (
<div className="relative flex h-72 w-full max-w-lg items-center justify-center overflow-hidden rounded-xl border bg-card">
<div className="relative z-10 text-center">
<p className="text-2xl font-semibold tracking-tight text-foreground">Listening for events</p>
<p className="mt-1 text-sm text-muted-foreground">Rings breathe quietly behind the content.</p>
</div>
<Ripple />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/rippleInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/ripple.tsx// Ballmac UI: Ripple. https://ui.ballmac.com/components/ripple "use client" import * as React from "react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" type RippleProps = Omit<React.ComponentProps<"div">, "children"> & { /** Number of rings. */ circles?: number /** Diameter of the innermost ring in pixels. */ size?: number /** Extra diameter added by each ring in pixels. */ gap?: number /** Seconds for one slow breath of the rings. */ duration?: number } /** Concentric rings that breathe outward from the center of the nearest positioned parent. Decorative. */ function Ripple({ circles = 7, size = 200, gap = 70, duration = 4, className, ...props }: RippleProps) { const reduce = useReducedMotion() return ( <div data-slot="ripple" aria-hidden="true" className={cn("pointer-events-none absolute inset-0 flex items-center justify-center [mask-image:linear-gradient(to_bottom,black,transparent_90%)]", className)} {...props} > {Array.from({ length: circles }, (_, i) => { const d = size + i * gap return ( <motion.span key={i} className="absolute rounded-full border border-foreground/15 bg-foreground/[0.03]" style={{ width: d, height: d, opacity: Math.max(0.15, 1 - i * 0.14) }} animate={reduce ? undefined : { scale: [1, 0.9, 1] }} transition={{ duration, delay: i * 0.12, repeat: Infinity, ease: "easeInOut" }} /> ) })} </div> ) } type RippleBurst = { id: number; x: number; y: number; size: number } type ClickRippleProps = React.ComponentProps<"div"> & { /** Seconds a ripple takes to spread and fade. */ duration?: number /** Ripples start from the center of the element instead of the pointer. */ centered?: boolean } /** Wrap any surface so a press sends a ripple out from the pointer. Keyboard activation ripples from the center. */ function ClickRipple({ duration = 0.7, centered = false, className, children, onPointerDown, onKeyDown, ...props }: ClickRippleProps) { const reduce = useReducedMotion() const ref = React.useRef<HTMLDivElement>(null) const [bursts, setBursts] = React.useState<RippleBurst[]>([]) const next = React.useRef(0) function spawn(clientX?: number, clientY?: number) { const el = ref.current if (!el || reduce) return const box = el.getBoundingClientRect() const x = centered || clientX === undefined ? box.width / 2 : clientX - box.left const y = centered || clientY === undefined ? box.height / 2 : clientY - box.top const size = Math.hypot(Math.max(x, box.width - x), Math.max(y, box.height - y)) * 2 setBursts((all) => [...all, { id: next.current++, x, y, size }]) } return ( <div ref={ref} data-slot="click-ripple" onPointerDown={(e) => { onPointerDown?.(e) spawn(e.clientX, e.clientY) }} onKeyDown={(e) => { onKeyDown?.(e) if ((e.key === "Enter" || e.key === " ") && e.target === e.currentTarget) spawn() }} className={cn("relative isolate overflow-hidden", className)} {...props} > {children} <span aria-hidden="true" className="pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-[inherit]"> {bursts.map((b) => ( <motion.span key={b.id} className="absolute rounded-full bg-foreground/20" style={{ left: b.x - b.size / 2, top: b.y - b.size / 2, width: b.size, height: b.size }} initial={{ scale: 0, opacity: 1 }} animate={{ scale: 1, opacity: 0 }} transition={{ duration, ease: "easeOut" }} onAnimationComplete={() => setBursts((all) => all.filter((x) => x.id !== b.id))} /> ))} </span> </div> ) } export { Ripple, ClickRipple, type RippleProps, type ClickRippleProps }Update the import paths to match your project setup.
Usage
import { Ripple, ClickRipple } from "@/components/ballmac/ripple"The full example is in the Code tab above.
Examples
Press ripple on cards
import { ClickRipple } from "@/components/ballmac/ripple"
export default function RippleClick() {
return (
<div className="grid w-full max-w-md grid-cols-2 gap-3">
{["Inbox", "Drafts", "Sent", "Archive"].map((label) => (
<ClickRipple key={label} className="cursor-pointer rounded-xl border bg-card p-5 text-center text-sm font-medium select-none">
{label}
</ClickRipple>
))}
</div>
)
}API reference
<Ripple>
| Prop | Type | Default |
|---|---|---|
circlesNumber of rings. | number | 7 |
sizeDiameter of the innermost ring in pixels. | number | 200 |
gapExtra diameter added by each ring in pixels. | number | 70 |
durationSeconds for one slow breath of the rings. | number | 4 |
<ClickRipple>
| Prop | Type | Default |
|---|---|---|
durationSeconds a ripple takes to spread and fade. | number | 0.7 |
centeredRipples start from the center of the element instead of the pointer. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | All rings and ripples are aria-hidden and pointer-events-none |
| Reduced motion | Rings are still and press ripples are skipped |
Use with AI
<Ripple circles size gap duration /> fills its positioned parent. <ClickRipple centered duration> wraps a surface and ripples on pointer down, and from the center on Enter or Space when the wrapper itself has focus. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Ripple (@ballmac/ripple) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A calm focal point behind a central call to action
- Touch feedback on custom cards and tiles
Not for
- Standard buttons (the Ballmac button already has press feedback)
Registry JSON: https://ui.ballmac.com/r/ripple.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Orbiting Circles
Items that orbit a center on circular tracks while staying upright. Evenly spaced, any radius, speed and direction; stack several for rings. Paused off-screen, static under reduced motion.
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.