Domphy

rippleButton

A Buttons block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call rippleButton() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full behavioral port. Composes the ui button() patch for standard chrome (spec: 'looks like an ordinary button at rest'), adding position/overflow and a reactive ripple layer. Each click reads coordinates relative to the button's own bounding box (getBoundingClientRect, not the button's center), spawning a ripple sized to fully cover the button from any origin; ripples are tracked in a reactive keyed array (same pattern this package's animatedList.ts uses for its feed) so rapid repeated clicks produce multiple concurrently-animating ripples, each with a stable id used as its _key. Every ripple is auto-removed via a duration-matched setTimeout, with all pending timers cleared on unmount to avoid leaks. rippleColor is exposed as a ThemeColor role (default 'neutral', resolved near-white via a shift-0 edge tone) rather than the spec's literal RGB string, since Domphy forbids raw rgb()/hex on style props — matching the semi-transparent light/white default the spec itself calls out. Verified with jsdom tests covering click-position accuracy, auto-removal timing, and multi-ripple overlap, plus doctor-clean (0 findings) via the domphy-doctor CLI.

Status: ported · Reference: Magic UI original

// Magic UI "Ripple Button" — clean-room reimplementation.
//
// An ordinary button that, on every click, spawns a semi-transparent circle
// expanding out from the exact pointer position and fading away — classic
// tactile click feedback. Implemented purely from the block's public
// functional/visual spec — no upstream Magic UI source was viewed or copied.
//
// Each click reads its coordinates relative to the button's own bounding box
// (not the button's center) and pushes a new entry onto a reactive array —
// the same keyed-reactive-list pattern `animatedList.ts` uses for its feed —
// so rapid repeated clicks can have several ripples animating at once. Every
// ripple carries its own stable id (used as `_key`) and is dropped from the
// array via a `setTimeout` matching its animation `duration`, so the DOM
// never accumulates finished ripples.
//
// The upstream spec's `rippleColor` prop is a literal RGB string. Domphy's
// doctor rules forbid raw rgb/hex color literals on style props, so it is
// exposed as a `ThemeColor` role instead (default `"neutral"`, resolved at
// its lightest edge tone — a near-white ripple at partial opacity, matching
// the spec's own stated default) — the same tradeoff `animatedGradientText`
// documents for its own literal-color props.

import type { DomphyElement, Listener, StyleObject } from "@domphy/core";
import { hashString, toState } from "@domphy/core";
import { button } from "@domphy/ui";
import { type ThemeColor, themeColor } from "@domphy/theme";

export interface RippleButtonProps {
  /** Button label content. Defaults to `"Click me"`. */
  children?: DomphyElement | DomphyElement[] | string;
  /** `button()` patch color tone for the button's own chrome. Defaults to `"primary"`. */
  color?: ThemeColor;
  /** Theme color family the ripple wave is drawn from. Defaults to `"neutral"`. */
  rippleColor?: ThemeColor;
  /** One ripple's grow-and-fade cycle, in milliseconds. Defaults to `600`. */
  duration?: number;
  onClick?: (event: MouseEvent) => void;
  disabled?: boolean;
  style?: StyleObject;
}

interface RippleInstance {
  id: string;
  /** Click offset from the button's own left edge, in px. */
  x: number;
  /** Click offset from the button's own top edge, in px. */
  y: number;
  /** Final rendered diameter, in px — large enough to cover the button from any origin. */
  size: number;
}

let rippleButtonInstanceCounter = 0;

/** Normalizes a `DomphyElement | DomphyElement[] | string` prop into the flat
 * `(string | DomphyElement)[]` shape `DomphyElement<T>`'s content field expects — a
 * bare single element isn't part of that type, only primitives/arrays/functions are. */
function asContent(value: DomphyElement | DomphyElement[] | string): (string | DomphyElement)[] {
  return Array.isArray(value) ? value : [value];
}

/**
 * A themed button that spawns an expanding, fading circular ripple from the
 * exact click point every time it's pressed. Purely tactile — at rest it
 * looks like an ordinary button; the ripple only appears on interaction. Call
 * with no arguments for a working demo button.
 */
function rippleButton(props: RippleButtonProps = {}): DomphyElement<"button"> {
  const label = props.children ?? "Click me";
  const color = props.color ?? "primary";
  const rippleColor = props.rippleColor ?? "neutral";
  const duration = props.duration ?? 600;

  const instanceId = ++rippleButtonInstanceCounter;
  const rippleAnimationName = `ripple-button-wave-${hashString(
    JSON.stringify({ instanceId, duration }),
  )}`;
  const rippleKeyframes = {
    from: { transform: "translate(-50%, -50%) scale(0)", opacity: 0.45 },
    to: { transform: "translate(-50%, -50%) scale(1)", opacity: 0 },
  };

  const ripples = toState<RippleInstance[]>([], "ripples");
  let rippleIdCounter = 0;
  let pendingTimers: ReturnType<typeof setTimeout>[] = [];

  // `_doctorDisable` isn't part of core's strict `PartialElement` type — build through
  // an untyped literal, then assert, so the excess-property check doesn't fire (mirrors
  // `overlayCanvas` in confetti.ts).
  const rippleCircle = (ripple: RippleInstance): DomphyElement<"span"> =>
    ({
      span: null,
      _key: ripple.id,
      ariaHidden: "true",
      // Decorative wave with no text of its own — exempt from the missing-color contract.
      _doctorDisable: "missing-color",
      style: {
        position: "absolute",
        left: `${ripple.x}px`,
        top: `${ripple.y}px`,
        width: `${ripple.size}px`,
        height: `${ripple.size}px`,
        borderRadius: "50%",
        pointerEvents: "none",
        backgroundColor: (listener: Listener) => themeColor(listener, "shift-0", rippleColor),
        animation: `${rippleAnimationName} ${duration}ms ease-out forwards`,
        [`@keyframes ${rippleAnimationName}`]: rippleKeyframes,
      } as StyleObject,
    }) as DomphyElement<"span">;

  const rippleLayer: DomphyElement<"span"> = {
    span: (listener: Listener) => ripples.get(listener).map(rippleCircle),
    ariaHidden: "true",
    style: { position: "absolute", inset: 0, pointerEvents: "none" },
  };

  const buttonElement: DomphyElement<"button"> = {
    button: [{ span: asContent(label), style: { position: "relative", zIndex: 1 } }, rippleLayer],
    type: "button",
    disabled: props.disabled,
    $: [button({ color })],
    style: {
      position: "relative",
      overflow: "hidden",
      ...(props.style ?? {}),
    } as StyleObject,
    onClick: (event: MouseEvent) => {
      const targetElement = event.currentTarget as HTMLElement | null;
      if (targetElement) {
        const boundingBox = targetElement.getBoundingClientRect();
        const width = boundingBox.width || targetElement.offsetWidth;
        const height = boundingBox.height || targetElement.offsetHeight;
        const size = Math.max(width, height, 1) * 2;
        const x = event.clientX - boundingBox.left;
        const y = event.clientY - boundingBox.top;
        const id = `ripple-${instanceId}-${++rippleIdCounter}`;

        ripples.set([...ripples.get(), { id, x, y, size }]);

        const cleanupTimer = setTimeout(() => {
          ripples.set(ripples.get().filter((entry) => entry.id !== id));
          pendingTimers = pendingTimers.filter((timer) => timer !== cleanupTimer);
        }, duration);
        pendingTimers.push(cleanupTimer);
      }
      props.onClick?.(event);
    },
    _onRemove: () => {
      pendingTimers.forEach(clearTimeout);
      pendingTimers = [];
    },
  };

  return buttonElement;
}

export { rippleButton };

← Back to Magic UI catalog