interior[.]dev

Action Feedback·01.5

Ripple

Touch feedback from the pointer origin.

ripple

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { Ripple, useRipple } from "@/components/interior/ripple";

export function PinPad({ onDigit }: { onDigit: (d: string) => void }) {
  return (
    <div className="grid grid-cols-3 gap-2">
      {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((d) => (
        <Ripple key={d} onPress={() => onDigit(d)} className="h-12 w-full font-mono">
          {d}
        </Ripple>
      ))}
    </div>
  );
}

export function TrackRow({ title, onPlay }: { title: string; onPlay: () => void }) {
  const { bind, ripples, fadeDuration } = useRipple({ max: 2 });

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onPlay}
      {...bind}
      className="relative isolate flex h-14 items-center rounded-[11px] px-3 text-[13px] text-stone-700 dark:text-stone-200"
    >
      <span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
        {ripples.map((r) => (
          <span
            key={r.id}
            className="absolute block size-10 rounded-[14px] bg-stone-800/10 dark:bg-white/15"
            style={{
              left: r.x - 20,
              top: r.y - 20,
              transform: `scale(${r.scale})`,
              opacity: r.released ? 0 : 1,
              transition: `opacity ${fadeDuration}s linear`,
            }}
          />
        ))}
      </span>
      <span className="relative">{title}</span>
    </div>
  );
}

Source

components/interior/ripple.tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

const EASE = [0.23, 1, 0.32, 1] as const;
const BLOOM = { duration: 0.5, ease: "linear" } as const;
const BASE = 40;

export type RippleSpec = {
  id: number;
  x: number;
  y: number;
  scale: number;
  released: boolean;
};

export type UseRippleOptions = {
  disabled?: boolean;
  max?: number;
  minVisible?: number;
  fade?: number;
};

export function useRipple({
  disabled = false,
  max = 4,
  minVisible = 220,
  fade = 320,
}: UseRippleOptions = {}) {
  const [ripples, setRipples] = useState<RippleSpec[]>([]);

  const list = useRef<RippleSpec[]>([]);
  const seq = useRef(0);
  const born = useRef(new Map<number, number>());
  const timers = useRef(new Map<number, ReturnType<typeof setTimeout>[]>());
  const pointers = useRef(new Map<number, number>());
  const keyed = useRef<number | null>(null);

  const commit = useCallback((next: RippleSpec[]) => {
    list.current = next;
    setRipples(next);
  }, []);

  const forget = useCallback((id: number) => {
    timers.current.get(id)?.forEach(clearTimeout);
    timers.current.delete(id);
    born.current.delete(id);
  }, []);

  const spawn = useCallback(
    (el: HTMLElement, clientX?: number, clientY?: number) => {
      const rect = el.getBoundingClientRect();
      const x = Math.round(
        clientX === undefined ? rect.width / 2 : clientX - rect.left,
      );
      const y = Math.round(
        clientY === undefined ? rect.height / 2 : clientY - rect.top,
      );
      const reach = Math.max(
        Math.hypot(x, y),
        Math.hypot(rect.width - x, y),
        Math.hypot(x, rect.height - y),
        Math.hypot(rect.width - x, rect.height - y),
      );

      let next = list.current;
      while (next.length >= max) {
        forget(next[0].id);
        next = next.slice(1);
      }

      const id = (seq.current += 1);
      born.current.set(id, performance.now());
      commit([
        ...next,
        {
          id,
          x,
          y,
          scale: Math.round((reach * 200) / BASE) / 100,
          released: false,
        },
      ]);
      return id;
    },
    [commit, forget, max],
  );

  const release = useCallback(
    (id: number) => {
      if (timers.current.has(id)) return;
      if (!list.current.some((r) => r.id === id)) return;

      const wait = Math.max(
        0,
        minVisible - (performance.now() - (born.current.get(id) ?? 0)),
      );

      const start = setTimeout(() => {
        commit(
          list.current.map((r) => (r.id === id ? { ...r, released: true } : r)),
        );
      }, wait);

      const drop = setTimeout(() => {
        forget(id);
        commit(list.current.filter((r) => r.id !== id));
      }, wait + fade);

      timers.current.set(id, [start, drop]);
    },
    [commit, fade, forget, minVisible],
  );

  const releaseAll = useCallback(() => {
    pointers.current.forEach((id) => release(id));
    pointers.current.clear();
    if (keyed.current !== null) {
      release(keyed.current);
      keyed.current = null;
    }
  }, [release]);

  const endPointer = useCallback(
    (pointerId: number) => {
      const id = pointers.current.get(pointerId);
      if (id === undefined) return;
      pointers.current.delete(pointerId);
      release(id);
    },
    [release],
  );

  useEffect(() => {
    const bail = () => releaseAll();
    const onVisibility = () => document.hidden && releaseAll();
    window.addEventListener("blur", bail);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      window.removeEventListener("blur", bail);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [releaseAll]);

  useEffect(() => {
    const pending = timers.current;
    return () => {
      pending.forEach((set) => set.forEach(clearTimeout));
      pending.clear();
    };
  }, []);

  const bind = {
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
      if (disabled) return;
      if (e.pointerType === "mouse" && e.button !== 0) return;
      if (pointers.current.has(e.pointerId)) return;
      e.currentTarget.setPointerCapture?.(e.pointerId);
      pointers.current.set(
        e.pointerId,
        spawn(e.currentTarget, e.clientX, e.clientY),
      );
    },
    onPointerUp: (e: React.PointerEvent<HTMLElement>) => endPointer(e.pointerId),
    onPointerCancel: (e: React.PointerEvent<HTMLElement>) =>
      endPointer(e.pointerId),
    onLostPointerCapture: (e: React.PointerEvent<HTMLElement>) =>
      endPointer(e.pointerId),
    onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => {
      if (disabled || e.repeat || keyed.current !== null) return;
      if (e.key !== " " && e.key !== "Enter") return;
      keyed.current = spawn(e.currentTarget);
    },
    onKeyUp: (e: React.KeyboardEvent<HTMLElement>) => {
      if (keyed.current === null) return;
      if (e.key !== " " && e.key !== "Enter" && e.key !== "Escape") return;
      release(keyed.current);
      keyed.current = null;
    },
    onBlur: () => releaseAll(),
  };

  return { bind, ripples, fadeDuration: fade / 1000 };
}

export type RippleProps = {
  children: React.ReactNode;
  onPress?: () => void;
  disabled?: boolean;
  max?: number;
  tintClassName?: string;
  className?: string;
};

export function Ripple({
  children,
  onPress,
  disabled = false,
  max = 4,
  tintClassName = "bg-stone-800/15 dark:bg-white/20",
  className = "",
}: RippleProps) {
  const { bind, ripples, fadeDuration } = useRipple({ disabled, max });
  const reduced = useReducedMotion();

  return (
    <button
      type="button"
      disabled={disabled}
      onClick={onPress}
      style={{ touchAction: "manipulation", WebkitTapHighlightColor: "transparent" }}
      className={`relative isolate inline-flex select-none items-center justify-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3.5 py-2 text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-white/25 ${className}`}
      {...bind}
    >
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]"
      >
        {ripples.map((r) => (
          <motion.span
            key={r.id}
            className={`absolute block rounded-full ${tintClassName}`}
            style={{
              left: r.x - BASE / 2,
              top: r.y - BASE / 2,
              width: BASE,
              height: BASE,
              willChange: "transform, opacity",
            }}
            initial={{ scale: reduced ? r.scale : 0, opacity: 0 }}
            animate={{ scale: r.scale, opacity: r.released ? 0 : 1 }}
            transition={{
              scale: reduced ? { duration: 0 } : BLOOM,
              opacity: {
                duration: r.released ? fadeDuration : 0.07,
                ease: r.released ? EASE : "linear",
              },
            }}
          />
        ))}
      </span>

      <span className="relative">{children}</span>
    </button>
  );
}

Props

children
React.ReactNode

The label of the pressable surface. It is painted above the ripple layer, so the bloom never washes it out.

onPressundefined
() => void

Fired on click, which means pointer and keyboard activation both route through the same native path.

disabledfalse
boolean

Disables the button and refuses to spawn ripples, so a dead control never gives live feedback.

max4
number

Ceiling on simultaneous blooms. A hammered key evicts the oldest instead of growing the DOM without limit.

tintClassName"bg-stone-800/15 dark:bg-white/20"
string

The bloom fill. Swap it when the surface is dark, inverted, or tinted with a brand color.

className""
string

Appended last, so the caller's radius, height and padding win. The clip layer reads border-radius with rounded-[inherit], so overriding the radius still clips correctly.