bottega

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-button

Usage

Usagetsx
<RippleButton>
  {/* children */}
</RippleButton>

Props

PropTypeDefaultDescription
children*React.ReactNodeButton label or content.
variantenumdefaultVisual style; maps to token combinations.
sizeenummdPadding + font-size scale; `md` is default.
rippleColorstringOverrides the auto-derived ripple tint. Defaults to a variant-driven tint: a white ink-film on `default`, `var(--accent)` on `ghost`/`outline`.
durationnumber520Full ripple lifecycle duration in ms. Default 520.
disabledbooleanfalseSuppresses ripple and interaction entirely.
asChildbooleanfalseRadix-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