Keep the button's own size fixed (`min-width`) and cross-fade only the inner content — label, then spinner, then checkmark. If the button resizes per state, surrounding layout jitters distractingly, so lock the width to the longest state (the label).
Represent the three states (idle, loading, done) as classes (`.loading`, `.done`), and respect a minimum loading duration (say 400ms+) alongside the real async request — if the response comes back too fast, the spinner flashes and disappears, which reads as jittery rather than reassuring.
Disable the button while loading (via `disabled` or by ignoring clicks) to prevent double submission. Plan for the failure case too — typically a brief error state (red outline, a shake) before returning to idle.
When to use
Make this the default for any button action with latency — form submits, payments, API calls. Not needed for instant local toggles.