Domphy

statefulButton

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

Implementation notes

Full idle -> loading -> success -> idle state machine: a keyed single-item content array (_key = phase name) reconciles each transition as an unmount/mount pair, giving each phase its own motion() enter (slide-up+fade-in) and exit (slide-down+fade-out); the spinner is @domphy/ui's spinner() patch, success is an inline checkmark glyph, idle label is bold via strong(). Button visually narrows via a reactive paddingInline (CSS-transitioned) while non-idle. successHoldDuration is exposed as an optional prop (default 2000ms) since the spec notes upstream exposes no such prop but implies a fixed ~2s hold -- this is a reasonable, backward-compatible enhancement (unset behavior matches spec). Verified: tsc clean, doctor 0 diagnostics, all tests pass including a fake-timer-driven full state-cycle test.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Stateful Button" — clean-room reimplementation ("inspired by
// the design of buttons on Family", per the reference page's own note).
//
// A button that morphs its content through idle label -> loading spinner ->
// success checkmark -> back to idle, giving inline feedback for an async
// click action. Implemented purely from the block's public functional/visual
// spec — no upstream Aceternity source was viewed or copied.
//
// The three states are mutually exclusive single-item arrays keyed by state
// name (`_key: "idle" | "loading" | "success"`) — the same keyed-swap
// technique `animatedList.ts`/`rippleButton.ts` use for their own dynamic
// entries. Domphy reconciles a changed `_key` as an unmount-then-mount pair,
// so each state's `motion()` patch gets its own enter (`initial` -> `animate`)
// and exit (`exit`) transition for free: a slide-up-and-fade in, slide-down-
// and-fade out. The button's own `paddingInline` is a reactive function of the
// same state, so it visually narrows toward a compact square while the
// spinner/checkmark (no label text) are shown, and widens back once the idle
// label returns — animated via a plain CSS `transition` on `padding-inline`.
import type { DomphyElement, Listener, State, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { motion, spinner, strong } from "@domphy/ui";
import { themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export type StatefulButtonPhase = "idle" | "loading" | "success";

export interface StatefulButtonProps {
  /** Idle label content. Defaults to `"Send message"`. */
  children?: string | DomphyElement | DomphyElement[];
  /** Click handler; may return a `Promise` — its resolve timing drives the loading-to-success transition. */
  onClick?: (event: MouseEvent) => void | Promise<unknown>;
  className?: string;
  disabled?: boolean;
  type?: "button" | "submit" | "reset";
  /** How long the success checkmark holds before reverting to idle, in ms. Defaults to `2000`. */
  successHoldDuration?: number;
  style?: StyleObject;
}

function asContent(value: string | DomphyElement | DomphyElement[]): (string | DomphyElement)[] {
  return Array.isArray(value) ? value : [value];
}

/** Small checkmark glyph, matching `interactiveHoverButton.ts`'s inline-SVG icon pattern. */
function checkGlyph(): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [{ polyline: null, points: "20 6 9 17 4 12" }],
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2.5",
        strokeLinecap: "round",
        strokeLinejoin: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: { display: "inline-flex", width: themeSpacing(5), height: themeSpacing(5) },
  };
}

const SLIDE_TRANSITION = { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" };

/** Builds the single visible content item for a given phase, keyed so Domphy
 * reconciles a phase change as an unmount-then-mount pair (driving `motion()`'s
 * enter/exit transitions on each swap). */
function contentForPhase(phase: StatefulButtonPhase, label: (string | DomphyElement)[]): DomphyElement<"span"> {
  const shared = {
    _key: phase,
    style: { display: "inline-flex", alignItems: "center", justifyContent: "center" } as StyleObject,
    $: [motion({ initial: { opacity: 0, y: 10 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -10 }, transition: SLIDE_TRANSITION })],
  };
  if (phase === "loading") {
    return { span: [{ span: null, $: [spinner({ color: "neutral" })] }], ...shared } as DomphyElement<"span">;
  }
  if (phase === "success") {
    return { span: [checkGlyph()], ...shared } as DomphyElement<"span">;
  }
  return { span: label, ...shared } as DomphyElement<"span">;
}

/**
 * A button that morphs its content from an idle label into a compact loading
 * spinner, then a success checkmark, before automatically reverting — inline
 * async-action feedback in the style of Family's buttons. Call with no
 * arguments for a working demo — a "Send message" button.
 */
function statefulButton(props: StatefulButtonProps = {}): DomphyElement<"button"> {
  const label = asContent(props.children ?? "Send message");
  const idleLabel: (string | DomphyElement)[] = [{ strong: label, $: [strong({ color: "neutral" })] } as DomphyElement<"strong">];
  const successHoldDuration = props.successHoldDuration ?? 2000;

  const phase: State<StatefulButtonPhase> = toState<StatefulButtonPhase>("idle");
  let successTimer: ReturnType<typeof setTimeout> | null = null;

  const clearSuccessTimer = () => {
    if (successTimer) {
      clearTimeout(successTimer);
      successTimer = null;
    }
  };

  const handleClick = async (event: MouseEvent) => {
    if (phase.get() !== "idle") return;
    clearSuccessTimer();
    phase.set("loading");
    try {
      await props.onClick?.(event);
    } finally {
      phase.set("success");
      successTimer = setTimeout(() => {
        successTimer = null;
        phase.set("idle");
      }, successHoldDuration);
    }
  };

  // Hand-rolls the button chrome instead of composing the `button()` patch: that
  // patch's `color` prop drives BOTH background and hover/focus/disabled states off
  // a single reactive tone, which would fight the fixed-dark `dataTone` anchor and
  // the reactive `paddingInline` this component needs for its own width-morph
  // (same tradeoff `rainbowButton.ts`/`shimmerButton.ts` make for their own bespoke
  // container chrome).
  const buttonElement: DomphyElement<"button"> = {
    button: (listener: Listener) => [contentForPhase(phase.get(listener), idleLabel)],
    type: props.type ?? "button",
    disabled: props.disabled,
    class: props.className,
    dataTone: "shift-15",
    ariaBusy: (listener: Listener) => (phase.get(listener) === "loading" ? "true" : "false"),
    style: {
      position: "relative",
      appearance: "none",
      border: "none",
      cursor: props.disabled ? "not-allowed" : "pointer",
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      overflow: "hidden",
      boxSizing: "border-box",
      minHeight: (listener: Listener) => themeSpacing(themeDensity(listener) * 4 + 12),
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      paddingInline: (listener: Listener) =>
        phase.get(listener) === "idle"
          ? themeSpacing(themeDensity(listener) * 4)
          : themeSpacing(themeDensity(listener) * 1),
      borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 1),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", "neutral"),
      color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
      opacity: props.disabled ? 0.6 : 1,
      transition: "padding-inline 220ms cubic-bezier(0.22, 1, 0.36, 1)",
      ...(props.style ?? {}),
    } as StyleObject,
    onClick: handleClick,
    _onRemove: () => {
      clearSuccessTimer();
    },
  };

  return buttonElement;
}

export { statefulButton };

← Back to Aceternity UI catalog