Something new is coming.Join the waitlist
All posts
tent ui team

Building Animated UI Components with Framer Motion and shadcn/ui

A practical tutorial for animating shadcn/ui components with Framer Motion — covering layout transitions, AnimatePresence, spring physics, and how to keep interactions accessible.

framer motionshadcn uianimationreacttutorial

Animation is a multiplier. A button that confirms a save with a spring-loaded check badge feels three times as fast as one that flashes a green border. The difference is not vibes — it is that motion gives the user a clear, traceable signal of what just happened.

This post is a hands-on tutorial for building real animated components on top of shadcn/ui using Framer Motion (the motion/react package). We will look at the animated save button that ships with tent ui, deconstruct how it works, and then build a smaller variant from scratch.

You can follow along in any React project that has shadcn/ui installed.


What we are building

The end result is a button with three states:

  • Idle — the resting state with a label
  • Loading — a spinner badge appears, label morphs to "Saving"
  • Success — the spinner is replaced with a check, label becomes "Saved", then resets

Three things make it feel polished:

  1. Per-character text transitions — letters animate in and out individually instead of fading the whole label at once
  2. A spring-physics badge — the success indicator pops in instead of appearing instantly
  3. Layout animation — the badge expands or contracts based on its content

All three are one or two lines of Framer Motion.


Setting up the dependencies

If you are starting fresh, install the building blocks:

npm install motion lucide-react

The package is motion, not framer-motion — Framer Motion 11+ rebranded the npm package. The React entry point is motion/react.

For TypeScript, no extra setup is needed; motion/react ships its own types.


The skeleton: a button with three states

Start with a regular button and a useState for the status:

"use client";
 
import { useState } from "react";
 
type Status = "idle" | "loading" | "success";
 
export function SaveButton() {
  const [status, setStatus] = useState<Status>("idle");
 
  async function handleClick() {
    if (status !== "idle") return;
    setStatus("loading");
    await new Promise((r) => setTimeout(r, 1200));
    setStatus("success");
    setTimeout(() => setStatus("idle"), 2000);
  }
 
  return (
    <button onClick={handleClick} disabled={status !== "idle"}>
      {status === "idle" ? "Save" : status === "loading" ? "Saving" : "Saved"}
    </button>
  );
}

This works. It is not satisfying. The label snaps between three states with no continuity, and the user has no visual signal that something happened. Animation fixes both.


Step 1: animate the label

Replace the static label with characters that animate in and out individually:

import { AnimatePresence, motion } from "motion/react";
 
function AnimatedChars({ text }: { text: string }) {
  return (
    <AnimatePresence mode="popLayout" initial={false}>
      {text.split("").map((char, i) => (
        <motion.span
          key={`${char}-${i}`}
          layout
          initial={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
          animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ opacity: 0, scale: 0, filter: "blur(4px)" }}
          transition={{ type: "spring", stiffness: 500, damping: 30 }}
          className="inline-block"
        >
          {char === " " ? " " : char}
        </motion.span>
      ))}
    </AnimatePresence>
  );
}

A few details that matter:

  • mode="popLayout" tells AnimatePresence to remove characters from the layout immediately on exit, so the new label can flow in without waiting for the exit animation
  • layout on each motion.span makes Framer Motion smoothly tween character positions when the word length changes — Save (4 chars) → Saving (6 chars) just works
  • The blur filter is the trick that makes characters feel like they materialize instead of pop. Spring physics handles the scale, and blur softens the edges during transit

Drop <AnimatedChars text={label} /> into your button and the label transitions are done.


Step 2: the success badge with AnimatePresence

The badge is a small element that appears in the corner of the button. It needs to enter when the button leaves idle and exit when it returns:

<AnimatePresence mode="wait">
  {status !== "idle" && (
    <motion.div
      initial={{ opacity: 0, scale: 0, x: -8, filter: "blur(4px)" }}
      animate={{ opacity: 1, scale: 1, x: 0, filter: "blur(0px)" }}
      exit={{ opacity: 0, scale: 0, x: -8, filter: "blur(4px)" }}
      transition={{ type: "spring", stiffness: 300, damping: 20 }}
      className="absolute -top-1 -right-1 size-6 rounded-full bg-blue-500 text-white"
    >
      {/* spinner or check */}
    </motion.div>
  )}
</AnimatePresence>

mode="wait" makes the next child wait until the previous one finishes exiting — useful when the badge content swaps from spinner to check. x: -8 on enter and exit gives the badge a slight slide that reads as "arriving" rather than "blinking."

Inside the badge, you nest a second AnimatePresence to swap between the spinner and the check icon:

<AnimatePresence mode="popLayout">
  {status === "loading" && (
    <motion.div
      key="loader"
      exit={{ scale: 0, opacity: 0 }}
      transition={{ duration: 0.2 }}
    >
      <Spinner />
    </motion.div>
  )}
  {status === "success" && (
    <motion.div
      key="check"
      initial={{ scale: 0, opacity: 0, filter: "blur(4px)" }}
      animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
      transition={{ type: "spring", stiffness: 500, damping: 25 }}
    >
      <Check className="size-3.5" />
    </motion.div>
  )}
</AnimatePresence>

Three things to notice. The spinner exits with a fast tween (no spring) because spinners exiting with a wobble feels wrong. The check enters with a spring that overshoots slightly — that overshoot is what makes success feel celebratory. And both have unique key props so AnimatePresence treats them as distinct elements rather than morphing one into the other.


Step 3: spring physics, briefly

The two springs we used:

  • { stiffness: 500, damping: 30 } — fast, almost no bounce. Right for the label text where you want responsiveness over personality.
  • { stiffness: 500, damping: 25 } — slightly less damping, slightly more bounce. Right for the success check where the overshoot signals "done."

Stiffness controls speed; damping controls bounce. A mass of 1 is the default and rarely needs tuning. The numbers feel right because they emulate physical objects — too stiff feels jittery, too soft feels late.


Accessibility

Animations are great for sighted users but can break for users who set prefers-reduced-motion. Framer Motion exposes the useReducedMotion() hook:

import { useReducedMotion } from "motion/react";
 
const reduced = useReducedMotion();
const transition = reduced
  ? { duration: 0 }
  : { type: "spring", stiffness: 500, damping: 30 };

Drop transition into every motion element and the entire interaction becomes instant for users who opt out. The state still changes; only the choreography is removed.

Also: keep the button focusable, use a real <button> element, and never animate something into existence that the user needed to click immediately. Motion is decoration, not gating.


Skip the build, copy the source

If you want this exact button without recreating it, tent ui ships it with the registry CLI:

npx shadcn@latest add https://ui.srb.codes/r/animated-save-button.json

The component lands in components/ui/animated-save-button.tsx as the same code you saw in this post — edit it, restyle it, replace the spring values, swap the icon. The point of the registry pattern is that you own the source the moment you add it.

If you want a similar treatment on a different interaction — a copy button, an animated counter, a step indicator — the same primitives apply: AnimatePresence for mount/unmount, layout for size changes, springs for personality, and useReducedMotion for accessibility.

That is most of Framer Motion in three hundred lines. The rest is taste.