Ripple Button

A button that sends a circle out from wherever it was pressed, sized to reach the furthest corner.

Loading preview

Installation

npx shadcn@latest add @loomui/ripple-button

Usage

import { RippleButton } from "@/components/ui/ripple-button"
<RippleButton className="rounded-md px-5 py-2.5 text-sm">Press me</RippleButton>

A ripple is feedback about where the press landed, not decoration, so it starts at the pointer rather than the middle of the button. Starting it in the middle every time throws away the only information the effect carries.

Each circle is sized from the press point to the furthest corner. That is why a press near an edge throws a bigger circle than one in the middle. Both finish covering the button, and both finish at the same moment, so the button never looks like it was pressed harder in one place than another.

The circle is a scale on an absolutely positioned layer sitting behind the label at -z-10. Scaling costs a composited layer and nothing else, where growing a width would relayout the button on every frame of every press.

Ripples are kept in state and dropped on animationend rather than on a timer, so a tab that is backgrounded mid-press does not leave one behind. Presses stack, so a fast series of clicks overlaps instead of cutting the last one off.

Colour and pace

<RippleButton
  color="color-mix(in oklch, var(--accent) 30%, transparent)"
  duration={900}
>
  Slower, tinted
</RippleButton>

color defaults to 22% of currentColor. The ripple picks up the button's own text colour, so it works on a dark face and a light one without being told which. Keep whatever you replace it with translucent. The circle passes under the label, and a solid one takes the label with it.

Under about 300ms the ripple is gone before the eye finds it. Past about 1000ms it is still crossing when the next press arrives. 620 is the default because it reads as a response to the press rather than an animation about it.

Props

PropTypeDefaultDescription
durationnumber620Milliseconds one ripple takes to cross and fade.
colorstring22% currentColorFill of the circle.
disabledbooleanfalseIgnore presses and throw no ripple.
classNamestringnoneMerged onto the button. Padding and type here.

Any other <button> prop is forwarded.

Accessibility

Enter and Space throw a ripple from the middle of the button, so a keyboard press gets the same acknowledgement a pointer press does instead of silence. Held keys repeat, and repeats are ignored, so one press is one ripple. The circles are aria-hidden decoration on top of a real <button>, and prefers-reduced-motion skips them without touching the click.