Ripple Button
component / form
Your own Button with a disc that expands from the point it was pressed — pointer or keyboard, clipped to the button's radius, in currentColor so it reads on every variant.
- type
- component
- files
- 1
- registry deps
- button
- npm deps
- none
installs
- components/automindz/ripple-button.tsx
- Wraps the consumer's own Button — every variant and size, unchanged
- The disc starts at the pointer and reaches the furthest corner, not half the box
- Enter and Space ripple from the centre; disabled and aria-disabled do not
- No stylesheet and no npm dependency — the expansion is one Web Animations call
- Reduced motion emits no disc at all; the press falls back to Button's active state
There is no stylesheet to import and no config file — that is the point. The expansion is a Web Animations API call on the disc's own element, so there is no @keyframes to ship, nothing to add to your root layout and nothing that silently stops working when someone forgets the import. It is "use client" for one reason: the ripple starts where the pointer landed, and CSS cannot read a pointer position — there is no selector for "12px in from the left edge" and no attr() that sees a coordinate — so the origin has to come from an event. It is a drop-in for your own Button and not a new button: RippleButtonProps is React.ComponentProps<typeof Button>, so every variant, size, render, className and handler passes straight through, and swapping <Button> for <RippleButton> changes nothing but the ripple. rippleButtonRadius(width, height, x, y) is exported and is the whole item: the disc has to reach the corner **furthest from the press**, so a press in a corner of a 200x40 button travels ~204px against the ~102px a press in the middle needs — sizing from the centre (max(width, height) / 2, or half the diagonal) is right for exactly one click position and short for every other. Keyboard presses carry no coordinates, so Enter and Space ripple from the centre rather than not at all, and auto-repeat is ignored so a held Enter emits one disc per press. disabled and aria-disabled do not ripple; there is **no loading prop**, because the pending pattern is the one the rest of this registry already uses (disabled={busy} aria-busy={busy} with a spinner in the label) and disabled already covers it. Under prefers-reduced-motion: reduce no disc is created at all — not a faster one — and the press feedback falls back to Button's own active:translate-y-px; the setting is read at press time, so turning it on mid-session takes effect immediately. The tint is currentColor at 22%, which is the only choice that needs no per-variant table: a fixed white ripple vanishes on secondary and a fixed black one vanishes on default, while the button's own ink contrasts with its own fill by construction. The clip is a pointer-events-none, aria-hidden layer with rounded-[inherit] and overflow-hidden, **not** overflow-hidden on the button — that would move an inline-flex button's baseline and shift every button in your app against adjacent text. rounded-full and every size therefore clip correctly with no radius to keep in sync. Eight discs in flight is a hard ceiling (oldest dropped): each disc removes itself when its animation finishes, but a backgrounded tab defers finish events, so the cap is what stops a hammered marketing button growing the DOM.
Variations
one state per section
- 01Every Button variant
- 02The origin is the press, and the reach is the far corner
- 03Keyboard presses ripple from the centre
- 04Every size, clipped to its own radius
- 05Disabled, aria-disabled and pending
- 06Hammer it, and check reduced motion
Every Button variant
Press each one: the disc is currentColor at 22%, so it takes the button's own ink and reads on a dark fill and a light one without a per-variant table. A fixed white ripple disappears on secondary and a fixed black one disappears on default — that is the bug this avoids.
The origin is the press, and the reach is the far corner
Press the far left edge, then the far right: the disc starts under the pointer and still covers the whole button. A press in a corner of this button travels about twice as far as one in the middle — rippleButtonRadius() is the hypotenuse of the two furthest edges, not half the box.
Keyboard presses ripple from the centre
Tab to either button and hold Enter, or press Space. Enter and Space carry no coordinates, so the disc starts in the middle — the same gesture, centred. Auto-repeat is ignored, so holding Enter emits one disc per press rather than one per keystroke.
Every size, clipped to its own radius
xs through lg and the icon sizes, each with a different corner radius from the same variant table. The clip is rounded-[inherit] on a layer of its own, not overflow-hidden on the button — that would move an inline-flex button's baseline and shift it against adjacent text.
Disabled, aria-disabled and pending
None of the first three ripple: a disc is a confirmation and there is nothing to confirm. Press Save changes — the pending state is the registry's existing one (disabled + aria-busy + a spinner), so there is no second loading prop here to keep in sync with it. The last button does ripple, and that is the point: aria-disabled is Booleanish, so the string "false" marks a control enabled — and it is truthy, so a plain truthiness test switches the ripple off on a button that works everywhere else. It is exactly what a spread from a form library hands you.
Hammer it, and check reduced motion
Click as fast as you can: discs overlap rather than queue, each removes itself when its animation finishes, and eight in flight is a hard ceiling so a hammered marketing button cannot grow the DOM. Then turn on prefers-reduced-motion (DevTools → Rendering) and press again: no disc is created at all, and the press still reads through the button's own active state.
