How to Add Smooth Animations to Any shadcn/ui Component with Framer Motion
A step-by-step tutorial for adding spring animations, entrance effects, and layout transitions to existing shadcn/ui components using Framer Motion — without rewriting your components from scratch.
shadcn/ui gives you clean, accessible components that you own outright. What it does not give you is motion. Every shadcn component is static by design — the library is intentionally minimal. Adding animation is your job, and Framer Motion is the right tool for it.
This tutorial shows you how to add smooth animations to any shadcn/ui component — not a specific one, but the repeatable pattern you can apply to buttons, dialogs, dropdowns, or anything else in your project.
The core insight: wrap, don't replace
The biggest mistake when animating shadcn components is trying to modify the component source. shadcn components are yours to edit, but animation logic mixed into component internals creates a maintenance headache. The cleaner approach is to wrap the component in a motion.* element or promote the root element to a motion element.
There are three patterns:
- Wrap with a motion div — for components where you control the outer container
- Replace the root element via
asChild— for components that accept Radix'sasChildprop - Use the
motionwrapper directly — when you want the component itself to animate, usingmotion.create()
Setup
Install the packages if you have not already:
The package is motion, not framer-motion. The React API lives at motion/react.
import { motion, AnimatePresence } from "motion/react";Pattern 1: Animate on mount with a wrapper
The simplest case: you want a component to animate in when it appears on screen. Wrap it in a motion.div:
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
export function AnimatedButton() {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
>
<Button>Save changes</Button>
</motion.div>
);
}The initial state defines where the animation starts — invisible and shifted 8px down. animate is the target — fully visible and in position. The spring transition makes it feel physical rather than mechanical.
This pattern works on any component: <Card>, <Badge>, <Alert>, or any div you control.
Pattern 2: Animate with asChild on shadcn buttons
shadcn components built on Radix primitives accept an asChild prop that merges props onto the child element. This lets you use a motion.button as the actual DOM element:
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
const MotionButton = motion.create(Button);
export function SpringButton() {
return (
<MotionButton
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
Save
</MotionButton>
);
}motion.create(Button) creates a motion-aware version of the Button component. whileHover and whileTap are gesture-based — they apply automatically when the user interacts, and spring back when the gesture ends. No onMouseEnter handlers needed.
This is the pattern for interactive animations on buttons, cards, list items — anything the user clicks or hovers.
Pattern 3: Animate a dialog or sheet on open/close
Modal components need to animate in when opened and out when closed. AnimatePresence handles the exit animation — without it, React unmounts the component instantly and the exit animation never plays:
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/ui/button";
export function AnimatedDialog() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open</Button>
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
>
<motion.div
key="dialog"
className="w-full max-w-sm rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 12 }}
transition={{ type: "spring", stiffness: 500, damping: 32 }}
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">Dialog title</h2>
<p className="text-muted-foreground mt-2 text-sm">
Dialog content goes here.
</p>
<Button className="mt-4" onClick={() => setOpen(false)}>
Close
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}Two things to note. First, the backdrop and dialog are two separate motion elements — the backdrop fades while the dialog scales and slides. Second, exit mirrors initial so the animation reverses on close, which feels natural to users.
If you are using the shadcn <Dialog> component, you can intercept the portal content with the same technique by wrapping the DialogContent internals in motion.div — the radix portal renders into the DOM normally and motion elements work inside it.
Accessibility: respect reduced motion
Always check for prefers-reduced-motion. Framer Motion makes this one line:
import { useReducedMotion } from "motion/react";
export function AccessibleAnimation() {
const reduced = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: reduced ? 0 : 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.3 }}
>
Content
</motion.div>
);
}When the user has reduced motion enabled, skip position transforms but keep opacity — most motion-sensitive users are fine with fades, just not with movement.
Skip the implementation: install from tent ui
If you want a production-ready animated button component with state-aware badge animations, per-character label transitions, and spring physics already tuned — install the AnimatedSaveButton from the tent ui registry:
The component lands in your project as editable source. It demonstrates all three patterns above (motion wrapper, motion.create, AnimatePresence) in a single real-world component. Use it as a reference for animating the rest of your shadcn components, or use it directly.
More components with built-in animations are available at ui.srb.codes/docs/components.
Which pattern to use
The patterns compose. A dialog can use AnimatePresence for mount/unmount AND whileHover on internal elements. Start with one and add as needed — there is no overhead until you add it.