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>
);
}
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.
pnpm dlx shadcn@latest add https://matos-ui.com/r/reactive-button.jsonThe 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>
)
}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><ReactiveButton
status={status}
loadingText="Publishing..."
successText="Published"
errorText="Try again"
>
Publish
</ReactiveButton>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>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>
)
}| Prop | Type | Default |
|---|---|---|
variant | "default" | "secondary" | "outline" | "ghost" | "destructive" | "link" | "default" |
size | "sm" | "md" | "lg" | "icon" | "md" |
status | "idle" | "loading" | "success" | "error" | "idle" |
loadingText | ReactNode | "Loading..." |
successText | ReactNode | "Done" |
errorText | ReactNode | "Try again" |
countdown | number | - |
icon | ReactNode | - |
loadingIcon | ReactNode | Animated spinner |
successIcon | ReactNode | Animated check |
errorIcon | ReactNode | Animated alert |
autoReset | boolean | false |
resetDelay | number | 1800 |
onStatusReset | () => void | - |
For icon-only buttons, provide a descriptive aria-label.
Also exported: reactiveButtonVariants, ReactiveButtonStatus, and ReactiveButtonProps.
Install Matos UI
Choose a package manager and copy one command for every component.