July 24, 2025

Login button

Demo

Code

tsx

import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { Spinner } from "../spinner";
import styles from "./login-button.module.css";
const buttonCopy = {
idle: "Send me a login link",
loading: (
<Spinner size={16} color="rgba(255, 255, 255, 0.65)" aria-label="Loading" />
),
success: "Login link sent!",
};
export function LoginButton() {
const [buttonState, setButtonState] = useState<
"idle" | "loading" | "success"
>("idle");
return (
<button
type="button"
className={styles.button}
disabled={buttonState !== "idle"}
onClick={async () => {
setButtonState("loading");
await new Promise((r) => setTimeout(r, 1500));
setButtonState("success");
setTimeout(() => {
setButtonState("idle");
}, 2000);
}}
>
<div aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={buttonState}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
bounce: 0.25,
}}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ y: 10, opacity: 0 }}
>
{buttonCopy[buttonState]}
</motion.span>
</AnimatePresence>
</div>
</button>
);
}

Notes

  • The popLayout mode on AnimatePresence is key here. wait will slow things down too much as it needs to wait for the exiting element to finish it's exit animation before animating the new element in.
  • Can choose to use a spring animation
  • Use of aria-live="polite" to ensure screen reader picks up on copy changes