# Click Ripple

> An animated click ripple for React and shadcn, a ring that spreads from the exact press point.

A press ring for clicks you want seen in demos, recordings and tutorials. A soft disc marks the press, then a 1.5px ring grows from that exact point on the smooth spring and fades. It rings at real presses on its parent and at a Demo Cursor's presses, so a scripted click and a real one look the same.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/click-ripple
- Installs: motion
- Registry: https://eased.dev/r/click-ripple.json

## Install

```bash
npx shadcn add https://eased.dev/r/click-ripple.json
```

A ring that spreads from the exact point of a press, built on Motion. It works on any positioned element, for real clicks and for a [Demo Cursor](/components/demo-cursor)'s scripted ones.

## When to use

- Product demos and recordings, where a click has to be seen to be followed.
- Tutorials and walkthroughs that replay a sequence of presses.
- A canvas or surface where the press point itself matters (a map, a whiteboard, a colour field).

## When not to use

- Ordinary buttons in an app: Eased's press scale is the feedback, and a ring on every click is noise.
- Confirming that something happened: say it in the label ([Button](/components/button) swaps "Save" to "Saved") or with a [Toast](/components/toast).

## Usage

```tsx example.tsx
import { ClickRipple } from "@/components/eased/click-ripple/click-ripple";

export function Example() {
  return (
    <div className="relative grid h-48 place-items-center rounded-panel bg-surface-sunken">
      Click anywhere
      <ClickRipple />
    </div>
  );
}
```

Mount it inside the element whose presses should ring; that element needs `position: relative` (or any positioning). Presses on its children bubble to it.

## Examples

### With a demo cursor

`trigger="demo"` rings only at the cursor's presses, so a viewer's own clicks on the stage stay quiet.

```tsx
<div ref={stage} inert className="relative">
  <Button data-demo="deploy">Deploy</Button>
  <DemoCursor to="deploy" pressed={step === "press"} />
  <ClickRipple trigger="demo" />
</div>
```

### A neutral ring

The ring is `currentColor`; set it with a text class.

```tsx
<ClickRipple className="text-text-muted" />
```

## API reference

### ClickRipple

| Prop | Type | Default | Description |
|---|---|---|---|
| `trigger` | `"pointer" \| "demo" \| "both"` | `"both"` | Real pointer presses on the parent, Demo Cursor presses inside it, or both. |
| `className` | `string` | `"text-brand-text"` | Sets the ring's colour (a text class) and anything else on the layer. |

A Demo Cursor press is an `eased-demo-press` event (`DEMO_PRESS` in eased-demo-core) with `{ clientX, clientY }`; dispatch your own to ring anywhere from a script.

## Accessibility

- The layer is `aria-hidden` and `pointer-events: none`; it never changes what a press does or what is announced.
- It draws over the parent only; nothing is focusable.

## Motion

A soft disc marks the press and is gone in 300ms on the exit curve. The ring, 56px across with a surface-coloured halo under a 1.5px stroke so it reads over filled buttons, grows from 30% on the `smooth` spring (no overshoot) while its opacity falls on the ease-in curve over 500ms, apart from the spring, so it never rings twice. At most four rings are in flight. Under reduced motion the ring shows at full size, holds for 300ms and fades in 100ms, without growing.

## Notes for AI

Use Click Ripple when a click should be visible to someone watching: a demo, a recording, a tutorial. Put it inside the stage next to a Demo Cursor with `trigger="demo"`, or inside any `relative` element for real clicks.

Do not wrap individual buttons in their own ripple when one ripple on their shared container covers them, and do not add it to production controls as general press feedback. It already clips nothing, takes no input and cleans up each ring when it fades.

## Related

- [Demo Cursor](/components/demo-cursor): the scripted presses it rings at.
- [Button](/components/button): press feedback for everyday controls.
- [Product demo set](/sets/product-demo): the pieces on one timeline.

## Example

`components/eased/click-ripple/click-ripple-demo.tsx`

```tsx
"use client";

import { useRef } from "react";
import { DEMO_PRESS, useDemoTimeline, type DemoPressDetail } from "../_primitives/demo-core/demo-core";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { ClickRipple } from "./click-ripple";

/** Where the opening presses land, as fractions of each element's box. */
const PRESSES = [
  { on: "canvas", at: [0.32, 0.4] },
  { on: "canvas", at: [0.7, 0.62] },
  { on: "save", at: [0.55, 0.55] },
] as const;

const STEPS = [
  { id: "wait", duration: 300 },
  ...PRESSES.map((_, i) => ({ id: `press-${i}`, duration: 650 })),
] as const;

/**
 * A recording-ready panel: every press rings where it lands, on the canvas and on the buttons.
 * It plays three presses on its own after a beat (Replay plays them again); then it is yours
 * to click.
 */
export function ClickRippleDemo() {
  const root = useRef<HTMLDivElement>(null);
  const timeline = useDemoTimeline(STEPS, {
    loop: false,
    observe: root,
    onStep: (id) => {
      const press = PRESSES[Number(id.split("-")[1])];
      const el = press && root.current?.querySelector(`[data-demo="${press.on}"]`);
      if (!el) return;
      const r = el.getBoundingClientRect();
      const detail: DemoPressDetail = { clientX: r.left + r.width * press.at[0], clientY: r.top + r.height * press.at[1] };
      el.dispatchEvent(new CustomEvent(DEMO_PRESS, { bubbles: true, detail }));
    },
  });

  return (
    <DemoFrame
      title="Click Ripple"
      actions={
        <Button variant="ghost" size="sm" edge="end" onClick={timeline.restart}>
          Replay
        </Button>
      }
    >
      {/* One ripple for the whole panel: presses on the canvas and the buttons all bubble to it. */}
      <div ref={root} className="relative flex min-h-0 flex-1 flex-col gap-4 p-(--pad) select-none">
        <div data-demo="canvas" className="flex min-h-0 flex-1 cursor-pointer items-center justify-center rounded-panel border border-line-subtle bg-surface-sunken">
          <p className="text-ui-sm text-text-muted">Click anywhere</p>
        </div>
        <div className="flex items-center justify-end gap-2">
          <Button>Cancel</Button>
          <Button data-demo="save" variant="primary">
            Save changes
          </Button>
        </div>
        <ClickRipple />
      </div>
    </DemoFrame>
  );
}
```
