Ripple
A Material-style ink ripple that blooms from the exact point of contact, with a spring press-dip and rebound. Concurrent ripples stack; keyboard activation blooms from center.
motionfree
"use client";
import { AnimatePresence, motion, type HTMLMotionProps } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export type RippleSize = "sm" | "md" | "lg";
export interface RippleProps extends React.ComponentPropsWithoutRef<"button"> {
/** Ripple ink color. Any CSS color; drawn at low opacity so it tints, never covers. @default soft white */
color?: string;
/** Padding / font scale of the button. @default "md" */
size?: RippleSize;
/** Peak opacity of the expanding ink, 0–1. Lower reads more restrained. @default 0.35 */
intensity?: number;
/** Seconds an ink drop takes to expand across the face and fade. @default 0.6 */
duration?: number;
/** Corner radius in px. @default 10 */
radius?: number;
/**
* Render the passed child as the root element instead of a `<button>`,
* merging in the ripple layer, press feedback, className, style and refs.
* The child must forward props to a real interactive element. @default false
*/
asChild?: boolean;
}
interface Drop {
id: number;
/** Origin x within the button, in px. */
x: number;
/** Origin y within the button, in px. */
y: number;
/** Diameter that covers the whole face from this origin, in px. */
size: number;
}
const SIZE_CLASSES: Record<RippleSize, string> = {
sm: "px-4 py-2 text-xs gap-1.5",
md: "px-6 py-3 text-sm gap-2",
lg: "px-8 py-4 text-base gap-2.5",
};
/** Merge any number of refs (callback or object) into one callback ref. */
function mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T> {
return (value) => {
for (const ref of refs) {
if (typeof ref === "function") ref(value);
else if (ref) (ref as React.MutableRefObject<T | null>).current = value;
}
};
}
/** Compose two event handlers; the injected one runs first, then the child's own. */
function composeHandlers<E>(
theirs: ((event: E) => void) | undefined,
ours: (event: E) => void
): (event: E) => void {
return (event) => {
ours(event);
theirs?.(event);
};
}
/**
* Ripple — a Material-style ink ripple. On press a soft circle blooms from the
* exact point of contact and fades; several presses stack their own concurrent
* ripples. The face dips on a spring while held and rebounds on release.
* Keyboard activation (Enter/Space) blooms from the center. Neutral-elegant by
* default with a single `color` accent; `size` and `intensity` are the knobs.
* Under prefers-reduced-motion the expanding bloom is replaced by one quiet,
* full-face opacity flash — motion-free, never blank.
*/
export function Ripple({
color = "rgba(255,255,255,0.9)",
size = "md",
intensity = 0.35,
duration = 0.6,
radius = 10,
asChild = false,
className,
style,
disabled,
children,
onPointerDown,
onPointerUp,
onPointerLeave,
onClick,
...rest
}: RippleProps) {
const reducedMotion = useReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const [drops, setDrops] = React.useState<Drop[]>([]);
const [pressed, setPressed] = React.useState(false);
const nextId = React.useRef(0);
const spawn = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
const el = rootRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Keyboard-activated clicks report detail 0 and no real pointer position —
// bloom from the exact center in that case.
const isKeyboard = event.detail === 0;
const x = isKeyboard ? rect.width / 2 : event.clientX - rect.left;
const y = isKeyboard ? rect.height / 2 : event.clientY - rect.top;
// Diameter that reaches the farthest corner, so the ink always fills.
const dia = Math.hypot(Math.max(x, rect.width - x), Math.max(y, rect.height - y)) * 2;
const id = nextId.current++;
setDrops((prev) => [...prev, { id, x, y, size: dia }]);
window.setTimeout(
() => setDrops((prev) => prev.filter((d) => d.id !== id)),
// Keep the node past its animation (1.1x) so the fade completes.
(reducedMotion ? 0.36 : duration) * 1100
);
},
[duration, reducedMotion]
);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
if (!disabled) spawn(event);
onClick?.(event as React.MouseEvent<HTMLButtonElement>);
};
const handlePointerDown = (event: React.PointerEvent<HTMLElement>) => {
if (!disabled) setPressed(true);
onPointerDown?.(event as React.PointerEvent<HTMLButtonElement>);
};
const release = () => setPressed(false);
const handlePointerUp = (event: React.PointerEvent<HTMLElement>) => {
release();
onPointerUp?.(event as React.PointerEvent<HTMLButtonElement>);
};
const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
release();
onPointerLeave?.(event as React.PointerEvent<HTMLButtonElement>);
};
const pressTransition = reducedMotion
? { duration: 0.12, ease: "easeOut" as const }
: { type: "spring" as const, stiffness: 420, damping: 24 };
const rootClassName = cn(
"relative isolate inline-flex select-none items-center justify-center overflow-hidden rounded-[inherit]",
"border border-neutral-800 bg-neutral-900 font-medium text-white",
"transition-colors duration-200 hover:border-neutral-700 hover:bg-neutral-800",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"focus-visible:ring-[color:var(--ripple-color)]",
"disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
SIZE_CLASSES[size],
className
);
const rootStyle = {
borderRadius: radius,
"--ripple-color": color,
...style,
} as React.CSSProperties;
// The ink layer + label, shared by both the <button> and asChild paths.
const inkLayer = (
<span
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
>
<AnimatePresence>
{drops.map((drop) =>
reducedMotion ? (
// Reduced motion: a single quiet full-face flash, pinned to an
// explicit resting pose (opacity 0) so it never freezes visible.
<motion.span
key={drop.id}
className="absolute inset-0 rounded-[inherit]"
style={{ background: color }}
initial={{ opacity: intensity * 0.55 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.36, ease: "easeOut" }}
/>
) : (
<motion.span
key={drop.id}
className="absolute rounded-full"
style={{
left: drop.x,
top: drop.y,
translateX: "-50%",
translateY: "-50%",
background: `radial-gradient(circle, ${color} 0%, ${color} 58%, transparent 72%)`,
}}
initial={{ width: 0, height: 0, opacity: intensity }}
animate={{ width: drop.size, height: drop.size, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration, ease: [0.16, 0.6, 0.32, 1] }}
/>
)
)}
</AnimatePresence>
</span>
);
const scale = pressed && !disabled && !reducedMotion ? 0.96 : 1;
if (asChild) {
const child = React.Children.only(children) as React.ReactElement<
React.HTMLAttributes<HTMLElement> & { ref?: React.Ref<HTMLElement> }
>;
const childProps = child.props;
return React.cloneElement(
child,
{
ref: mergeRefs(rootRef, (child as unknown as { ref?: React.Ref<HTMLElement> }).ref),
className: cn(rootClassName, childProps.className),
style: {
...rootStyle,
...childProps.style,
transform: `scale(${scale})`,
transition: reducedMotion
? "transform 0.12s ease-out"
: "transform 0.42s cubic-bezier(0.34,1.56,0.64,1)",
},
onClick: composeHandlers(childProps.onClick, handleClick),
onPointerDown: composeHandlers(childProps.onPointerDown, handlePointerDown),
onPointerUp: composeHandlers(childProps.onPointerUp, handlePointerUp),
onPointerLeave: composeHandlers(childProps.onPointerLeave, handlePointerLeave),
} as React.HTMLAttributes<HTMLElement> & { ref: React.Ref<HTMLElement> },
<>
{inkLayer}
<span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
{childProps.children}
</span>
</>
);
}
return (
<motion.button
ref={rootRef as React.Ref<HTMLButtonElement>}
data-crucible="ripple"
className={rootClassName}
style={rootStyle}
disabled={disabled}
animate={{ scale }}
transition={pressTransition}
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
{...(rest as unknown as HTMLMotionProps<"button">)}
>
{inkLayer}
<span className="relative z-10 inline-flex items-center justify-center gap-[inherit]">
{children}
</span>
</motion.button>
);
}
Installation
CLI
npx shadcn@latest add @crucible/rippleManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| color | string | soft white | Ripple ink color. Any CSS color; drawn at low opacity so it tints, never covers. |
| size | RippleSize | "md" | Padding / font scale of the button. |
| intensity | number | 0.35 | Peak opacity of the expanding ink, 0–1. Lower reads more restrained. |
| duration | number | 0.6 | Seconds an ink drop takes to expand across the face and fade. |
| radius | number | 10 | Corner radius in px. |
| asChild | boolean | false | Render the passed child as the root element instead of a <button>, merging in the ripple layer, press feedback, className, style and refs. The child must forward props to a real interactive element. |
Also accepts all props of React.ComponentPropsWithoutRef<"button"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.