primitives
Ripple Button
A tactile button primitive that spawns a radial ink-blot from the exact pointer-down coordinate, sized to the button's diagonal so it floods the face at any aspect ratio, with a slight overshoot at peak and a thinner ghost ring trailing 80ms behind for a double-pulse echo. Variants (default/ghost/outline) and sizes (sm/md/lg) map to tokens; supports asChild composition. Fully accessible: native button semantics, focus-visible ring, keyboard operable, aria-hidden inert ripples.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/ripple-buttonUsage
Usagetsx
<RippleButton>
{/* children */}
</RippleButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children* | React.ReactNode | — | Button label or content. |
| variant | enum | default | Visual style; maps to token combinations. |
| size | enum | md | Padding + font-size scale; `md` is default. |
| rippleColor | string | — | Overrides the auto-derived ripple tint. Defaults to a variant-driven tint: a white ink-film on `default`, `var(--accent)` on `ghost`/`outline`. |
| duration | number | 520 | Full ripple lifecycle duration in ms. Default 520. |
| disabled | boolean | false | Suppresses ripple and interaction entirely. |
| asChild | boolean | false | Radix-Slot-style passthrough: render the single child element as the root (e.g. wrapping a Next.js `Link`) instead of a native `<button>`. NOTE: when `asChild` wraps a non-button element (e.g. `<a>`), the consumer is responsible for adding `role="button"` if button semantics are required — this component does not inject it. |
Source
ripple-button.tsxtsx
"use client";
import {
Children,
cloneElement,
isValidElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { motion, useReducedMotion } from "motion/react";
import type { HTMLMotionProps } from "motion/react";
import styles from "./ripple-button.module.css";
type Variant = "default" | "ghost" | "outline";
type Size = "sm" | "md" | "lg";
interface RippleInstance {
id: number;
/** Top-left of the square ripple box, in element-local px. */
left: number;
top: number;
/** Diameter = diagonal * 2 so it floods corner-to-corner at any aspect ratio. */
size: number;
}
export interface RippleButtonProps
extends Omit<HTMLMotionProps<"button">, "style" | "ref"> {
/** Button label or content. */
children: React.ReactNode;
/** Visual style; maps to token combinations. */
variant?: Variant;
/** Padding + font-size scale; `md` is default. */
size?: Size;
/**
* Overrides the auto-derived ripple tint. Defaults to a variant-driven tint:
* a white ink-film on `default`, `var(--accent)` on `ghost`/`outline`.
*/
rippleColor?: string;
/** Full ripple lifecycle duration in ms. Default 520. */
duration?: number;
/** Suppresses ripple and interaction entirely. */
disabled?: boolean;
/**
* Radix-Slot-style passthrough: render the single child element as the root
* (e.g. wrapping a Next.js `Link`) instead of a native `<button>`.
*
* NOTE: when `asChild` wraps a non-button element (e.g. `<a>`), the consumer is
* responsible for adding `role="button"` if button semantics are required —
* this component does not inject it.
*/
asChild?: boolean;
}
const SIZE_CLASSES: Record<Size, string> = {
sm: "h-9 px-3 text-xs gap-1.5",
md: "h-10 px-5 text-sm gap-2",
lg: "h-12 px-7 text-base gap-2.5",
};
const VARIANT_CLASSES: Record<Variant, string> = {
// hover lightens the face via a CSS brightness filter — no new token.
default: "bg-primary text-primary-foreground hover:brightness-110",
ghost: "bg-background text-foreground hover:bg-muted",
outline: "bg-background text-foreground border border-border hover:bg-muted",
};
const DEFAULT_DURATION = 520;
const PRESS_TRANSITION = { duration: 0.08, ease: "easeOut" } as const; // 80ms press — JS seconds, not the --duration CSS token
function cn(...parts: Array<string | false | undefined>) {
return parts.filter(Boolean).join(" ");
}
export function RippleButton({
children,
variant = "default",
size = "md",
rippleColor,
duration = DEFAULT_DURATION,
disabled = false,
asChild = false,
className,
onPointerDown,
...props
}: RippleButtonProps) {
// Mount-gate + null-safe reduce check (contract snippet): server and first
// client render are byte-identical (no ripple state, no whileTap transform),
// so there is no React #418 hydration mismatch.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const [ripples, setRipples] = useState<RippleInstance[]>([]);
const seq = useRef(0);
const tint =
rippleColor ??
(variant === "default"
? // ink-film overlay on the primary face, tinted from the on-primary token so it themes.
"color-mix(in oklch, var(--primary-foreground) 22%, transparent)"
: "color-mix(in oklch, var(--accent) 28%, transparent)");
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(e);
if (disabled) return;
// Reduced motion (null-safe via the hook): skip spawning ripple DOM nodes
// entirely. `reduce` already treats useReducedMotion()===null as reduced.
if (reduce) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Diameter = diagonal * 2 → always floods corner-to-corner.
const dim = Math.hypot(rect.width, rect.height) * 2;
const id = seq.current++;
setRipples((prev) => [
...prev,
{ id, left: x - dim / 2, top: y - dim / 2, size: dim },
]);
},
[disabled, onPointerDown, reduce],
);
const removeRipple = useCallback((id: number) => {
setRipples((prev) => prev.filter((r) => r.id !== id));
}, []);
const rootClassName = cn(
"relative inline-flex select-none items-center justify-center overflow-hidden font-medium",
"rounded-[var(--radius)] outline-none",
"transition-[filter,background-color] duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
!disabled && "cursor-pointer",
SIZE_CLASSES[size],
VARIANT_CLASSES[variant],
className,
);
// Inner press-scale wrapper carries the transform so it never clips the
// overflow-hidden ripple container (which lives on the root). Reduced motion
// (and pre-mount) is fully static — no transform at all.
const rippleStyle = (r: RippleInstance): React.CSSProperties => ({
left: r.left,
top: r.top,
width: r.size,
height: r.size,
backgroundColor: tint,
["--ripple-duration" as string]: `${duration}ms`,
});
const innerContent = asChild
? (isValidElement(children)
? ((children.props as { children?: React.ReactNode }).children ?? null)
: children)
: children;
const inner = (
<>
<motion.span
className="relative z-10 inline-flex items-center justify-center gap-[inherit]"
whileTap={animate && !disabled ? { scale: 0.97 } : undefined}
transition={PRESS_TRANSITION}
>
{innerContent}
</motion.span>
<span aria-hidden="true" className={styles.container}>
{ripples.map((r) => (
<span
key={r.id}
className={styles.wave}
onAnimationEnd={() => removeRipple(r.id)}
style={rippleStyle(r)}
/>
))}
{ripples.map((r) => (
<span key={`ghost-${r.id}`} className={styles.ghost} style={rippleStyle(r)} />
))}
</span>
</>
);
// asChild: merge our root props onto the single child element (lightweight
// Slot — no extra dependency). The child becomes the root; the ripple layer +
// press wrapper replace its children.
if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) return null;
const childProps = child.props as { className?: string };
// A non-button element (e.g. <a>) can't take the native `disabled` attr, so
// suppress activation ourselves: block click + keyboard (Enter/Space), and
// pull it out of the tab order. The pointerdown handler already early-returns.
const disabledGuards = disabled
? {
onClick: (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") {
e.preventDefault();
e.stopPropagation();
}
},
tabIndex: -1,
}
: undefined;
return cloneElement(
child as React.ReactElement<Record<string, unknown>>,
{
...props,
...disabledGuards,
onPointerDown: handlePointerDown,
className: cn(rootClassName, childProps.className),
"aria-disabled": disabled || undefined,
},
inner,
);
}
return (
<motion.button
{...props}
type="button"
disabled={disabled}
onPointerDown={handlePointerDown}
className={rootClassName}
>
{inner}
</motion.button>
);
}
Dependencies
- motion
- @bottega/tokens