Button Loading State
Choose button loading for async actions whose feedback belongs exactly where the user acted.
Use
Preventing duplicate submissions while keeping feedback anchored to the control the user clicked.
Avoid
Avoid for instant local toggles, links, or actions where disabling the button would hide available recovery.
Safer Alternative
Spinner / Loading CircleRisk Level
Use carefully
Timing
Show only after roughly 250-300ms to avoid flashing on fast actions; keep width stable throughout.
Easing
Use a quick ease-out opacity or icon swap; the spinner itself should rotate linearly.
Risk
Live Demo
What It Is
A button that transitions into a loading state upon being clicked, usually by replacing text with a spinner or adding a spinner next to the text while disabling further clicks.
Decision Guidance
Choose button loading for async actions whose feedback belongs exactly where the user acted.
Best For
Preventing duplicate submissions while keeping feedback anchored to the control the user clicked.
Avoid When
Avoid for instant local toggles, links, or actions where disabling the button would hide available recovery.
Timing
Show only after roughly 250-300ms to avoid flashing on fast actions; keep width stable throughout.
Easing
Use a quick ease-out opacity or icon swap; the spinner itself should rotate linearly.
Risk Tags
✓When to Use
- Form submissions
- Saving data
- Async actions that take >300ms
✕When NOT to Use
- Instant client-side actions
- Navigation links
Configuration Tips
- 01Disable the button while loading to prevent double submissions
- 02Ensure the button does not resize when the text swaps to a spinner
You've Seen It In
AI Implementation Prompts
Move from a fast scaffold to production details, then tune timing and edge states.
Create a submit button that shows a loading spinner when clicked and disables itself.
Reference Implementation
The same implementation approach used by the live preview, kept compact enough to inspect and adapt.
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
export function ButtonLoadingStateDemo({ isPlaying = false }: { isPlaying?: boolean }) {
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isPlaying) {
setIsLoading(true);
const timer = setTimeout(() => setIsLoading(false), 2000);
return () => clearTimeout(timer);
} else {
setIsLoading(false);
}
}, [isPlaying]);
return (
<button
onClick={() => setIsLoading(true)}
disabled={isLoading}
className="relative flex h-12 w-40 items-center justify-center rounded-xl bg-stone-900 text-sm font-medium text-white shadow-sm transition-all hover:bg-stone-800 disabled:cursor-not-allowed disabled:bg-stone-900"
>
<AnimatePresence mode="popLayout" initial={false}>
{isLoading ? (
<motion.div
key="spinner"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.2 }}
className="flex items-center justify-center"
>
<Loader2 className="h-5 w-5 animate-spin text-stone-300" />
</motion.div>
) : (
<motion.span
key="text"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
Save Changes
</motion.span>
)}
</AnimatePresence>
</button>
);
}