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
popLayoutmode on AnimatePresence is key here.waitwill 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