Skip to content

Ripple Rings

Concentric rings pulse outward behind an icon.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ripple-rings.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. 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

PropTypeDefaultDescription
children*ReactNode–The icon or avatar the rings pulse behind.
countnumber3Number of rings in flight.
colorstring"var(--primary)"Ring color.
durationnumber2.4Seconds for one ring to travel out.
scalenumber2.6How 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.