Stateful Button is a shadcn/ui component that manages visual feedback for asynchronous operations in your applications.
It extends the standard shadcn/ui Button component to manage loading, progress, success, and error states.
Features
âš¡ State Machine Architecture: XState powers predictable transitions between idle, loading, success, and error states.
🔄 Dual Display Modes: Switch between spinner mode for indeterminate operations and progress mode with controlled 0-100 value tracking.
♿ Accessibility First: ARIA live regions and customizable screen reader messages for all state transitions.
🎯 Promise Integration: Async/await compatible onClick handlers with automatic state management based on resolution or rejection.
Use Cases
- Form Submissions: Provide feedback when a user submits a form that communicates with a backend service.
- E-commerce Checkouts: Show a loading state during payment processing and then a success or error message.
- File Uploads: Display a progress bar to indicate the upload status of large files.
- Data Processing: Indicate that a complex data processing task is running in the background.
How to Use It
1. Add the StatefulButton to your project with the shadcn/ui CLI. This command adds stateful-button.tsx to your components/ui directory. It also adds stateful-button-machine.ts to your lib directory.
pnpm dlx shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
npx shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
yarn shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json
bunx --bun shadcn@latest add https://stateful-button.vercel.app/r/stateful-button.json2. The default mode shows a spinner while an asynchronous onClick handler executes. This mode is suitable for actions where progress is indeterminate.
import { StatefulButton } from '@/components/ui/stateful-button';
// Example of a generic API call.
// Replace these with your own fetch, axios, trpc, etc.
const fetchData = async () => {
const response = await fetch('/api/endpoint');
if (!response.ok) throw new Error('Request failed');
return response.json();
};
export default function StatefulButtonDemo() {
return (
<StatefulButton
onClick={async () => {
// Trigger the API call
const data = await fetchData();
// Here you could update state, trigger notifications, or handle data
console.log('Received data:', data);
}}
/* Called when onClick completes successfully */
onComplete={() => console.log('Operation completed successfully')}
/* Called if the API call or onClick throws an error */
onError={(error) => console.error('An error occurred:', error)}
>
Load
</StatefulButton>
);
}3. You can use progress mode to display a progress bar for operations with trackable advancement. You must set the buttonType prop to progress and provide a controlled value between 0 and 100 to the progress prop.
import { useState } from 'react';
import { StatefulButton } from '@/components/ui/stateful-button';
async function processMultiApiRequests(setProgress: (p: number) => void) {
const totalApis = 3;
let completed = 0;
// Example API calls. Replace these with your own fetch, axios, trpc, etc.
// You can run them in parallel as shown here or sequentially depending on your workflow.
const apiCalls = [
async () => {
await fetch('/api/endpoint1');
},
async () => {
await fetch('/api/endpoint2');
},
async () => {
await fetch('/api/endpoint3');
}
];
// Run all APIs in parallel and update progress as each one finishes
await Promise.all(
apiCalls.map(async (call) => {
await call();
completed++;
setProgress(Math.floor((completed / totalApis) * 100));
})
);
// Ensure progress ends at 100
setProgress(100);
}
export function StatefulButtonProgressDemo() {
const [progress, setProgress] = useState(0);
return (
<StatefulButton
buttonType="progress"
/* Controlled progress value from 0 to 100 */
progress={progress}
/* Triggered when the button is clicked to start async logic */
onClick={() => processMultiApiRequests(setProgress)}
/* Called when all async work is done successfully */
onComplete={() => console.log('All APIs completed')}
/* Called if any error occurs during async logic */
onError={(error) => console.error(error)}
>
Run APIs
</StatefulButton>
);
}4. The StatefulButton component accepts all standard HTML button attributes and shadcn/ui button variants. Below are its specific props.
| Prop | Type | Default | Description |
|---|---|---|---|
onClick | (event) => void | Promise<unknown> | – | A click handler invoked when the button is pressed. It can return a Promise for asynchronous actions. |
onComplete | () => void | – | A callback triggered when the action completes successfully. |
onError | (error: Error) => void | – | A callback triggered if onClick throws an error or a promise rejects. |
buttonType | 'spinner' | 'progress' | 'spinner' | Specifies the button’s behavior mode. 'spinner' shows a loading spinner, while 'progress' displays a progress bar. |
progress | number | – | The current progress value from 0 to 100. This is a controlled prop for the progress bar. |
children | React.ReactNode | – | The content to render inside the button when it is in the idle state. |
ariaMessages | AriaMessages | English defaults | Customizable ARIA messages for accessibility across different states. |
variant | string | 'default' | The visual style of the button, inherited from shadcn/ui’s Button component. |
size | string | 'default' | The size of the button, inherited from shadcn/ui’s Button component. |
Related Resources
- shadcn/ui: The component library this button is designed to work with.
- XState: The state management library used to power the button’s state transitions.
- Radix UI: The underlying unstyled component library that shadcn/ui is built upon.
FAQs
Q: How do I prevent multiple clicks during loading states?
A: The component automatically disables the button during loading, progress, success, and error states. You don’t need additional click prevention logic.
Q: Can I customize the success and error state durations?
A: Yes, edit the state machine in lib/stateful-button-machine.ts. The default transitions reset to idle after 2 seconds, but you can modify the delay values or add custom transition logic.
Q: Does the progress prop need to reach exactly 100?
A: No, the component transitions to success when the onClick Promise resolves, regardless of the progress value. Set progress to 100 before resolution for best user experience.
Q: Can I use this with server actions in Next.js?
A: Yes, pass your server action directly to onClick. The component handles both client-side async functions and server actions that return Promises.




