Ripple Rings
Concentric rings pulse outward behind an icon.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/ripple-rings.jsonManual
Install the dependencies
npm install clsx tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Copy the source
components/ui/ripple-rings/ripple-rings.tsx"use client"; import type { ComponentProps, CSSProperties, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface RippleRingsProps extends Omit<ComponentProps<"div">, "children"> { /** The icon or avatar the rings pulse behind. */ children: ReactNode; /** Number of rings in flight. @default 3 */ count?: number; /** Ring color. @default "var(--primary)" */ color?: string; /** Seconds for one ring to travel out. @default 2.4 */ duration?: number; /** How far rings grow, as a multiple of the center's size. @default 2.6 */ scale?: number; /** "outline" rings or soft "filled" discs. @default "outline" */ variant?: "outline" | "filled"; } const KEYFRAMES = "@keyframes hantui-ripple{0%{transform:scale(1);opacity:.55}100%{transform:scale(var(--ripple-scale));opacity:0}}"; export function RippleRings({ children, count = 3, color = "var(--primary)", duration = 2.4, scale = 2.6, variant = "outline", className, style, ...props }: RippleRingsProps) { return ( <div className={cn("relative inline-grid place-items-center", className)} style={{ "--ripple": color, "--ripple-scale": scale, ...style } as CSSProperties} {...props}> <style href="hantui-ripple-rings" precedence="hantui"> {KEYFRAMES} </style> {Array.from({ length: count }, (_, i) => ( <span key={i} aria-hidden="true" className={cn( "absolute inset-0 rounded-full [animation:hantui-ripple_var(--ripple-duration)_cubic-bezier(0.22,1,0.36,1)_infinite] motion-reduce:[animation:none]", variant === "outline" ? "border-2 border-[var(--ripple)]" : "bg-[color-mix(in_oklab,var(--ripple)_30%,transparent)]", )} style={ { "--ripple-duration": `${duration}s`, animationDelay: `${(i * duration) / count}s`, } as CSSProperties } /> ))} {/* Reduced motion: rings rest at evenly spaced sizes instead of travelling. */} <span className="pointer-events-none absolute inset-0 hidden motion-reduce:block" aria-hidden="true"> {Array.from({ length: count }, (_, i) => ( <span key={i} className="absolute inset-0 rounded-full border border-[var(--ripple)]" style={{ transform: `scale(${1 + ((scale - 1) * (i + 1)) / (count + 1)})`, opacity: 0.35 - i * 0.08 }} /> ))} </span> <div className="relative">{children}</div> </div> ); }
Usage
import { RippleRings } from "@/components/ui/ripple-rings/ripple-rings";
<RippleRings>
<Avatar />
</RippleRings>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | The icon or avatar the rings pulse behind. |
count | number | 3 | Number of rings in flight. |
color | string | "var(--primary)" | Ring color. |
duration | number | 2.4 | Seconds for one ring to travel out. |
scale | number | 2.6 | How far rings grow, as a multiple of the center's size. |
variant | "outline" | "filled" | "outline" | "outline" rings or soft "filled" discs. |
Also accepts every native <div> attribute, including ref and className.
- Pure CSS keyframes (transform and opacity) with staggered delays, so rings leave at a steady rhythm. The keyframes ship inside the component.
- Rings scale with whatever size the center is, so the effect works for small badges and large avatars alike.
Accessibility
- The rings are decorative; describe what's happening ("Calling Maya…") in text next to it.
- With reduced motion the rings don't travel: they rest at evenly spaced sizes, fading outward.