Reactive Button

PreviousNext

Compact action button with polished loading, success, and error feedback for asynchronous flows.

"use client";

import { Send } from "lucide-react";
import { useEffect, useRef, useState } from "react";

import {
  ReactiveButton,
  type ReactiveButtonStatus,
} from "@/components/matos-ui/reactive-button";

function useDemoAction(
  result: Extract<ReactiveButtonStatus, "success" | "error">,
) {
  const [status, setStatus] = useState<ReactiveButtonStatus>("idle");
  const timerRef = useRef<number | null>(null);

  useEffect(
    () => () => {
      if (timerRef.current) {
        window.clearTimeout(timerRef.current);
      }
    },
    [],
  );

  function run() {
    if (timerRef.current) {
      window.clearTimeout(timerRef.current);
    }

    setStatus("loading");
    timerRef.current = window.setTimeout(() => {
      setStatus(result);
    }, 1100);
  }

  return { status, setStatus, run };
}

function useDeletionCountdown(initialValue = 9) {
  const [countdown, setCountdown] = useState<number | null>(null);

  useEffect(() => {
    if (countdown === null) {
      return;
    }

    const timer = window.setTimeout(() => {
      setCountdown((current) =>
        current === null || current <= 1 ? null : current - 1,
      );
    }, 1000);

    return () => window.clearTimeout(timer);
  }, [countdown]);

  function toggle() {
    setCountdown((current) => (current === null ? initialValue : null));
  }

  return { countdown, toggle };
}

export function ReactiveButtonDemo() {
  const send = useDemoAction("success");
  const save = useDemoAction("success");
  const remove = useDemoAction("error");
  const deletion = useDeletionCountdown();

  return (
    <div className="flex w-full max-w-2xl flex-col gap-5">
      <div className="flex flex-wrap items-center gap-3">
        <ReactiveButton
          status={send.status}
          loadingText="Sending..."
          successText="Sent"
          icon={<Send />}
          autoReset
          onStatusReset={() => send.setStatus("idle")}
          onClick={send.run}
        >
          Send message
        </ReactiveButton>
        <ReactiveButton
          variant="secondary"
          status={save.status}
          loadingText="Saving..."
          successText="Saved"
          autoReset
          onStatusReset={() => save.setStatus("idle")}
          onClick={save.run}
        >
          Save changes
        </ReactiveButton>
        <ReactiveButton
          variant="destructive"
          status={remove.status}
          loadingText="Deleting..."
          errorText="Failed"
          autoReset
          resetDelay={2200}
          onStatusReset={() => remove.setStatus("idle")}
          onClick={remove.run}
        >
          Delete item
        </ReactiveButton>
        <ReactiveButton
          variant="destructive"
          countdown={deletion.countdown ?? undefined}
          onClick={deletion.toggle}
        >
          {deletion.countdown === null
            ? "Schedule deletion"
            : "Cancel deletion"}
        </ReactiveButton>
      </div>

      <div className="flex flex-wrap items-center gap-2 border-border/60 border-t pt-4">
        <ReactiveButton size="sm">Default</ReactiveButton>
        <ReactiveButton size="sm" variant="secondary">
          Secondary
        </ReactiveButton>
        <ReactiveButton size="sm" variant="outline">
          Outline
        </ReactiveButton>
        <ReactiveButton size="sm" variant="ghost">
          Ghost
        </ReactiveButton>
        <ReactiveButton size="sm" variant="destructive">
          Destructive
        </ReactiveButton>
      </div>
    </div>
  );
}

When To Use

Use ReactiveButton when an action needs immediate, inline feedback without opening a toast or changing the surrounding layout. It works well for save, send, submit, and delete flows.

Keep using the regular Button for navigation, simple actions, and cases where the result is already visible elsewhere on the page.

The default loading, success, and error icons use small inline SVG animations. The button surface also reacts to each state: loading adds a quiet sweep, success fills with the theme success tone, and error settles with a restrained alert pulse. These effects become static when reduced motion is enabled.

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/reactive-button.json

Manual Status Control

The component is controlled through status. Update it when your asynchronous operation starts and finishes.

import { useState } from "react"
import { ReactiveButton, type ReactiveButtonStatus } from "@/components/matos-ui/reactive-button"

function SaveButton() {
  const [status, setStatus] = useState<ReactiveButtonStatus>("idle")

  async function save() {
    setStatus("loading")

    try {
      await saveChanges()
      setStatus("success")
    } catch {
      setStatus("error")
    }
  }

  return (
    <ReactiveButton status={status} onClick={save}>
      Save changes
    </ReactiveButton>
  )
}

Automatic Reset

Enable autoReset when the final state should return to idle after a short delay. Use onStatusReset to keep your external state synchronized.

<ReactiveButton
  status={status}
  autoReset
  resetDelay={1800}
  onStatusReset={() => setStatus("idle")}
>
  Send message
</ReactiveButton>

Custom Status Text

<ReactiveButton
  status={status}
  loadingText="Publishing..."
  successText="Published"
  errorText="Try again"
>
  Publish
</ReactiveButton>

Countdown Action

Pass countdown to reveal a circular counter with a smooth capsule expansion. This works well for reversible destructive actions, such as a short window to cancel a deletion.

<ReactiveButton
  variant="destructive"
  countdown={countdown ?? undefined}
  onClick={toggleDeletion}
>
  {countdown === null ? "Schedule deletion" : "Cancel deletion"}
</ReactiveButton>

React Hook Form

Use formState.isSubmitting for loading and update a local status after the submit promise resolves.

import { useState } from "react"
import { useForm } from "react-hook-form"
import { ReactiveButton, type ReactiveButtonStatus } from "@/components/matos-ui/reactive-button"

function ProfileForm() {
  const [status, setStatus] = useState<ReactiveButtonStatus>("idle")
  const { handleSubmit, formState } = useForm()

  async function onSubmit(values: unknown) {
    setStatus("loading")

    try {
      await updateProfile(values)
      setStatus("success")
    } catch {
      setStatus("error")
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <ReactiveButton
        type="submit"
        status={formState.isSubmitting ? "loading" : status}
        loadingText="Saving..."
        successText="Saved"
        autoReset
        onStatusReset={() => setStatus("idle")}
      >
        Save changes
      </ReactiveButton>
    </form>
  )
}

Reference

Props

PropTypeDefault
variant"default" | "secondary" | "outline" | "ghost" | "destructive" | "link""default"
size"sm" | "md" | "lg" | "icon""md"
status"idle" | "loading" | "success" | "error""idle"
loadingTextReactNode"Loading..."
successTextReactNode"Done"
errorTextReactNode"Try again"
countdownnumber-
iconReactNode-
loadingIconReactNodeAnimated spinner
successIconReactNodeAnimated check
errorIconReactNodeAnimated alert
autoResetbooleanfalse
resetDelaynumber1800
onStatusReset() => void-

For icon-only buttons, provide a descriptive aria-label.

Also exported: reactiveButtonVariants, ReactiveButtonStatus, and ReactiveButtonProps.