How to Animate a Button in React: Hover, Press, Loading and Done
A practical guide to button animation in React: the four states a button needs, timing values that feel right, CSS versus a spring library, and how to keep the loading state from fighting the press.
Core Takeaway
A button needs four visible states, not one: idle, hover or focus, pressed, and busy or done. Animate the press with a spring or a 90 to 140 ms transition, keep hover under 100 ms, and run the loading state on its own loop so it does not fight the press. Plain CSS covers the first three states. Use a spring library or a state machine file when the timing has to survive rapid clicks or when a designer needs to change it without a code change.
The four states, and why most buttons only have two
Most React buttons animate hover and stop there. The states that carry information are the ones around the action itself: pressed tells the user the click landed, busy tells them to wait, and done or failed tells them what happened. A button that goes straight from hover to a disabled grey blob loses all three messages.
- Idle: no motion, or a very slow ambient effect if the button is the primary action.
- Hover and focus: a small, fast change. Focus must be visible for keyboard users, not just hover for mice.
- Pressed: a short downward scale or a colour settle. This is the state that makes a button feel physical.
- Busy and done: a separate, looping indicator that does not reuse the press animation.
How long each state should take
Timing is the part people get wrong. A press that takes 300 ms feels laggy because the user has already released. Hover that takes 200 ms feels sticky. These ranges hold up across desktop and mobile:
- Hover and focus: 80 to 120 ms, ease-out.
- Press down: 60 to 90 ms. Release back to idle: 120 to 180 ms with a slight spring, because the return is the part the eye reads as bounce.
- Loading spinner rotation: roughly 700 to 900 ms per turn. Faster reads as an error, slower reads as frozen.
- Success check: 200 to 250 ms, once, then hold.
Route 1: CSS is enough for idle, hover and press
If the state lives entirely in the browser and nobody but a developer changes it, CSS handles this with no dependency. Use transform and opacity only, because they do not trigger layout. Drive the pressed state from :active and the focus state from :focus-visible so mouse users do not get a focus ring they did not ask for.
- transition: transform 90ms ease-out, background-color 90ms ease-out;
- button:active { transform: scale(0.97); }
- button:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
- Wrap the whole rule set in @media (prefers-reduced-motion: reduce) and drop the transform.
Route 2: a spring library when the press must survive fast clicks
CSS transitions restart from their current value, which is fine for one click and wrong for a double tap: the second press often jumps. A spring that carries velocity handles interruption cleanly. In React, Motion (the package formerly known as Framer Motion) is the common choice, and React Spring is the alternative when spring physics is the point rather than a detail. Both keep the animation in component code, which means the timing lives with the component and any change ships through a developer.
- Use a spring for the press scale, not a duration, so a second click continues from the current velocity.
- Keep the loading spinner on a CSS keyframe animation so it never needs to interrupt.
- Do not animate the button size with width or height. Animate transform so the layout does not reflow.
Route 3: a state machine file when the button is a product component
If the same button appears on web and in a mobile app, or a designer wants to tune the press feel without a pull request, put the states in a file instead of in component code. In ExodeUI you build idle, hover, pressed, loading and done as states in one .exode file, wire them to inputs and events, and render it with the runtime. React then only supplies data and listens for events.
- Expose inputs named for the data, such as disabled and label, rather than for the visuals.
- Emit an event when the press completes if the app needs to sequence something after it.
- Install with npm install exodeui and render <ExodeUICanvas src="/button.bexode" inputs={{ disabled }} onTrigger={...} />.
- Read the input names from the file before writing the React side, or the inputs silently do nothing.
The loading state is where most buttons break
Two mistakes are common. The first is disabling the button and leaving it looking identical, so the user clicks again. The second is letting the press animation and the spinner fight over the same transform. Give the spinner its own element and its own loop, and change the label to a verb in progress ("Saving") rather than a generic "Loading".
- Set aria-busy="true" while the action runs, and keep the button focusable unless the action truly cannot be cancelled.
- Reserve the width of the label so changing the text does not move the layout.
- After success, hold the check for about 1.5 seconds before returning to idle. Anything shorter reads as a flicker.
Accessibility checklist for an animated button
Motion is a preference, not a requirement. Respect it, and make sure the button still communicates every state when motion is gone.
- Honour prefers-reduced-motion: reduce by removing transforms and keeping the colour change.
- Never rely on animation alone to say the action failed. Pair it with text or an icon and a live region.
- Keep the focus ring visible in every state, including busy.
- Target at least a 44 by 44 pixel hit area, and keep it stable when the label changes.
Which route should you pick?
Pick CSS if one developer owns the button and it only ships to the web. Pick a spring library if the app is React-only and the press feel matters but not enough to add a design tool. Pick a state machine file if the component has more than about four states, if it also ships to iOS or Android, or if a designer is expected to tune it later. The cost of a file is a runtime; the cost of component code is that every timing change becomes a pull request.
- Simple marketing site: CSS.
- React product with gesture-heavy components: Motion or React Spring.
- Component that ships on web and mobile, or a design team that owns motion: a state machine file.
Production Implementation Snippet
// CSS route: no dependency, transform only
.btn {
transition: transform 90ms ease-out, background-color 90ms ease-out;
}
.btn:active { transform: scale(0.97); }
.btn:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.btn { transition: background-color 90ms ease-out; }
.btn:active { transform: none; }
}
// File route: React supplies data, the .exode file owns the motion
import { ExodeUICanvas } from 'exodeui';
export function SaveButton({ saving, disabled }) {
return (
<ExodeUICanvas
src="/save-button.bexode"
inputs={{ saving, disabled }}
onTrigger={(name) => {
if (name === 'press') submit();
}}
/>
);
}Frequently Asked Questions
Sources
- prefers-reduced-motion — MDN Web Docs
- CSS transitions and transition-duration — MDN Web Docs
- Understanding Success Criterion 2.3.3 (Animation from Interactions) — W3C WAI