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

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 uiframer motionanimationreacttutorial

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:

  1. Wrap with a motion div — for components where you control the outer container
  2. Replace the root element via asChild — for components that accept Radix's asChild prop
  3. Use the motion wrapper directly — when you want the component itself to animate, using motion.create()

Setup

Install the packages if you have not already:

npm install motion

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:

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

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

SituationPattern
Animate a container or card on mountWrap with motion.div
Add hover/tap feel to a buttonmotion.create(Button)
Animate in/out based on stateAnimatePresence + conditional render
Animate list items as they appearmotion.li inside a ul
Complex state-driven interactionsCombine all three

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.