Learn how to animate shadcn/ui components using Motion (Framer Motion) in Next.js. Includes real code for buttons, dialogs, scroll animations, drag interactions, variant orchestration, and more - written from production experience. By the end of this guide, you will be able to understand how the Shadcn Framer Motion combo can help in animating the components easily.
Part 1: Foundations of Shadcn Framer Motion Component

When you combine shadcn/ui with Framer Motion, you’re essentially blending structured UI primitives with fluid, production-grade animations.
The goal isn’t “adding animation everywhere.” It’s about making UI feel alive, responsive, and intentional.
Shadcn = Structure
-
Accessible components (built on Radix primitives)
-
Tailwind-first styling
-
Composable and scalable
Framer Motion = Behavior
-
Declarative animations
-
Gesture handling (hover, tap, drag)
-
Layout transitions
👉 Together: Structure + Motion = Polished UX
Introduction - Why Animation Still Matters (And Why Most Get It Wrong)
Let’s get something out of the way first: animation is not about making things look cool. It’s not about impressing your designer or showing off on Twitter. Animation, when done right, is a communication tool - it tells users what just happened, what’s about to happen, and where their attention should go.
Yet most developers either skip it entirely or go overboard with 600ms easing curves on every div. Both are wrong. And both hurt the user experience in different ways.
The UX Case for Animation
Think about the last time you clicked a button and nothing visually changed for half a second. Did you click it again? Probably. That’s a missing feedback animation causing a double-submission bug.
Or think about a modal that just pops into existence - no enter transition, no context - it feels jarring because your brain didn’t get the spatial cue that something new arrived on screen.
Animation solves real problems:
-
Feedback - confirms that an action was registered (button press, form submit)
-
Orientation - helps users understand where they are in a flow (page transitions, step indicators)
-
Hierarchy - draws attention to what matters (toast notifications, error states)
-
Continuity - makes UI feel connected rather than teleporting between states
The golden rule I’ve come to live by: if removing the animation makes the UI harder to understand, it was a good animation. If nothing changes, it was a decoration.
A 200ms spring on a button tap? Useful - it confirms the press. A 1.2s fade-in on your paragraph text? Annoying - it just delays reading.
Where shadcn/ui Ends and Motion Begins
shadcn/ui gives you beautifully structured, accessible, unstyled-but-styled components. It handles the hard stuff: keyboard navigation, ARIA roles, focus management, Radix UI primitives under the hood. What it intentionally doesn’t do is tell you how things should move.
That’s where Motion (formerly Framer Motion) comes in. Motion is a production-grade animation library for React that gives you declarative, physics-based animations with a remarkably clean API.
The two tools complement each other perfectly:
-
shadcn/ui owns the structure, accessibility, and base styles
-
Motion owns the entrance, exit, interaction, and transition behavior
The key insight - and something we’ll keep coming back to throughout this blog - is that you rarely replace shadcn components. You wrap or extend them with motion. The component still does its job; you’re just adding a layer of expressiveness on top.
What We’ll Build
By the end of this guide, you’ll have a solid, practical understanding of how to animate real shadcn/ui components in production Next.js apps.
We’ll go from the very basics of Motion’s API all the way to gesture-driven UIs, scroll-linked animations, and a satisfying animated Like Button that ties everything together.
Let’s build.
Setting Up Your Animated Stack
Before we write a single animation, let’s get the environment right. A messy setup leads to version conflicts, deprecated API warnings, and a lot of wasted time - so let’s do this properly.
Installing shadcn/ui + Motion in a Next.js Project
- Start with a fresh Next.js app if you don’t have one:
npx create-next-app@latest my-animated-app
cd my-animated-app
During setup, choose App Router, TypeScript, and Tailwind CSS - shadcn/ui requires all three.
- Next, initialize shadcn/ui:
npx shadcn@latest init
Follow the prompts. It’ll set up your components.json, configure path aliases, and add the base CSS variables. Now add the components we’ll use throughout this blog:
npx shadcn@latest add avatar button card dialog sheet accordion input label
- Now install Motion:
npm install motion
Note: The package is now simply
motion, notframer-motion. If you’re on an older project usingframer-motion, it still works - but new projects should usemotion. The import paths have changed slightly, which we’ll cover next.
That’s it. No extra config, no providers to wrap your app in. Motion works out of the box.
Project Structure for Animation-Heavy Apps
As your animation logic grows, keeping it organized matters. Here’s the structure I’ve settled on after a few production projects:
src/
├── app/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ # shadcn generated components (don't touch these)
│ └── animated/ # your motion-wrapped components live here
│ ├── AnimatedButton.tsx
│ ├── AnimatedDialog.tsx
│ └── ...
├── lib/
│ └── animations.ts # shared variants and transition configs
The lib/animations.ts file is one I swear by. Instead of writing inline animation objects everywhere, you define named variants once and import them:
// lib/animations.ts
import type { Transition, Variants } from "motion/react";
export const fadeIn: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const slideUp: Variants = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 20 },
};
export const springTransition: Transition = {
type: "spring",
stiffness: 300,
damping: 25,
};
This keeps your components clean and your animation language consistent across the app.
Usage:
import { motion } from "motion/react";
import { slideUp, springTransition } from "@/lib/animations";
export function Hero() {
return (
<motion.h1 {...slideUp} transition={springTransition}>
Welcome
</motion.h1>
);
}
The motion Component Mental Model - Thinking in Variants
Here’s the shift in thinking that makes Motion click: instead of imperatively saying “move this element from here to there”, you declare states and let Motion figure out the transition.
import { motion } from "motion/react";
// You're not saying "animate from opacity 0 to 1 over 300ms"
// You're saying "this element starts hidden, then becomes visible"
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
Hello
</motion.div>
When animate changes, Motion automatically transitions between states. That’s the mental model: you describe what the element looks like in each state, and Motion handles the in-between.
Variants take this further by giving names to those states:
const variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
<motion.div
variants={variants}
initial="hidden"
animate="visible"
>
Hello
</motion.div>
This might look like an extra ceremony for simple cases, but variants become incredibly powerful when you have parent-child relationships - more on that in Part 4. For now, just get comfortable with the idea that animations are named states, not imperative commands.
The Motion Primitives You Actually Need
Motion has a large API surface, but honestly? You can build 90% of production animations with about 6 concepts. Let’s go through the ones that matter.
initial, animate, exit - The Animation Lifecycle
These three props define the three key moments of an element’s life on screen.
import { motion, AnimatePresence } from "motion/react";
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }} // how it starts (before entering)
animate={{ opacity: 1, scale: 1 }} // how it looks when mounted
exit={{ opacity: 0, scale: 0.95 }} // how it leaves (before unmounting)
>
I animate in and out
</motion.div>
)}
</AnimatePresence>
A few things worth noting here:
initial is the starting state before the element animates in. If you set initial={false}, Motion skips the entrance animation - useful when you don’t want elements animating in on first page load.
exit only works inside <AnimatePresence>. This is a wrapper component that keeps track of components that are being unmounted and lets them finish their exit animation before actually removing them from the DOM. Without it, React removes the element instantly, and the exit animation never plays.
animate can be dynamic. You can pass a variable to animate and whenever it changes, Motion will transition to the new state:
<motion.div animate={{ x: isOpen ? 0 : -100 }}>
Slides based on state
</motion.div>
This is one of the most useful patterns - no useEffect, no manual transition management. Just change the state, and Motion handles the rest.
Usage:
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
export function ToggleBox() {
const [isVisible, setIsVisible] = useState(false);
return (
<div className="space-y-4">
<Button onClick={() => setIsVisible(!isVisible)}>Toggle</Button>
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
className="p-4 rounded-lg border bg-card"
>
I animate in and out!
</motion.div>
)}
</AnimatePresence>
</div>
);
}
transition Deep Dive - Duration, Ease, Springs vs Tweens
The transition prop controls how Motion gets from one state to another. This is where a lot of developers either set it and forget it (bad) or obsess over it endlessly (also bad). Here’s what you actually need to know.
Tween transitions are time-based - you specify a duration and an easing curve:
<motion.div
animate={{ opacity: 1 }}
transition={{
type: "tween", // default, can be omitted
duration: 0.3,
ease: "easeOut", // "linear" | "easeIn" | "easeOut" | "easeInOut" | custom bezier
}}
/>
Spring transitions are physics-based - instead of a fixed duration, the animation behaves like a physical spring:
<motion.div
animate={{ scale: 1 }}
transition={{
type: "spring",
stiffness: 300, // how stiff the spring is (higher = snappier)
damping: 20, // how much the spring resists motion (lower = more bounce)
mass: 1, // weight of the element (higher = slower)
}}
/>
When to use springs vs tweens?
Use springs for: element movement (x, y, scale, rotation), interactive elements like buttons and cards, and anything that responds to user gestures. Springs feel physical and alive - they’re what separates “animated” from “polished.”
Use tweens for: opacity, color, blur - properties where a physical bounce doesn’t make sense. A button fading in doesn’t need to bounce. A button popping into view does.
Here’s a practical cheat I use constantly:
// For UI movement - snappy spring
{ type: "spring", stiffness: 400, damping: 30 }
// For UI movement - bouncy spring
{ type: "spring", stiffness: 300, damping: 15 }
// For opacity / color fades
{ duration: 0.2, ease: "easeOut" }
whileHover, whileTap, whileFocus - these are shorthand props for defining animation states during interactions, without needing to manage any state yourself:
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ boxShadow: "0 0 0 3px rgba(99,102,241,0.4)" }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="outline-none px-3 py-1.5 rounded-md bg-indigo-600 text-white"
>
Click me
</motion.button>
When the user hovers, Motion transitions to the whileHover state. When they stop hovering, it transitions back to animate. No useState, no event handlers, no cleanup. It just works.
One gotcha here: don’t overdo the scale. scale: 1.05 On a button looks great. scale: 1.2 Looks broken. Keep hover scales between 1.02 and 1.06, tap scales between 0.94 and 0.98. Small numbers, big impact.
Putting It Together - Your First Animated shadcn Card
Let’s combine everything from Part 1 into a concrete example: an animated shadcn Card that fades in on mount and responds to hover.
// components/animated/AnimatedCard.tsx
"use client";
import { motion, type Variants } from "motion/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const cardVariants: Variants = {
hidden: { opacity: 0, y: 24 },
visible: { opacity: 1, y: 0 },
};
export function AnimatedCard({
title,
children,
}: {
title: string,
children: React.ReactNode,
}) {
return (
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover={{ y: -4, boxShadow: "0 12px 40px rgba(0,0,0,0.12)" }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
className="rounded-xl"
>
<Card className="h-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</motion.div>
);
}
Notice the pattern here - we’re not replacing Card with a motion component. We’re wrapping it with motion.div. The shadcn Card handles its own structure and styles; Motion handles how it moves. This wrapper pattern is the foundation for everything we’ll do in Part 2.
Usage:
import { AnimatedCard } from "@/components/animated/AnimatedCard";
export default function Page() {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
<AnimatedCard title="Performance">
<p className="text-sm text-muted-foreground">
Optimized for 60fps animations.
</p>
</AnimatedCard>
<AnimatedCard title="Accessible">
<p className="text-sm text-muted-foreground">
Respects reduced motion preferences.
</p>
</AnimatedCard>
<AnimatedCard title="Simple API">
<p className="text-sm text-muted-foreground">
Declarative and intuitive to use.
</p>
</AnimatedCard>
</div>
);
}
Part 2: Animating Shadcn UI Components

Animating Shadcn components is all about enhancing user experience without overloading it. Since Shadcn gives you clean, unstyled building blocks, you get full control over motion—no fighting against pre-built animation systems.
The most common and powerful way to animate Shadcn components is by integrating Framer Motion.
Why Add Animation?
Animation isn’t just decoration—it improves usability:
-
Guides attention (what changed, where to look)
-
Makes interactions feel responsive
-
Reduces perceived load time
-
Adds personality to otherwise static UI
But overdoing it? That kills UX. Keep it purposeful.
Animating Shadcn Buttons - More Than Just Hover Effects
Buttons are the most interacted-with element in any UI. Yet most developers slap a hover:scale-105 Tailwind class on them and call it a day. There’s nothing wrong with that - but once you bring Motion into the picture, you can make Shadcn buttons feel genuinely satisfying to press, not just visually different on hover.
Wrapping shadcn Button with Motion
The shadcn Button component renders a native <button> element under the hood. To animate it with Motion, you have two options:
Option A — Wrap with motion.div (quick but not ideal):
<motion.div
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.96 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="inline-flex"
>
<Button>Click me</Button>
</motion.div>
This works fine visually, but the animated element is a div, not the button itself — which means the click target and the animated element are different nodes in the DOM.
Option B — Animate motion.button directly, importing buttonVariants from shadcn (the right way):
// components/animated/AnimatedButton.tsx
"use client";
import { motion } from "motion/react";
import { VariantProps } from "class-variance-authority";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// Native HTML button events that conflict with Motion's own event types
type ConflictingEvents =
| "onDrag"
| "onDragEnd"
| "onDragStart"
| "onAnimationStart"
| "onAnimationEnd"
| "onAnimationIteration";
interface AnimatedButtonProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
ConflictingEvents
>,
VariantProps<typeof buttonVariants> {}
export function AnimatedButton({
children,
className,
variant,
size,
...props
}: AnimatedButtonProps) {
return (
<motion.button
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.96 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className={cn(
buttonVariants({ variant, size }),
"transition-none",
className
)}
{...props}
>
{children}
</motion.button>
);
}
One thing worth noting - "transition-none" The className prevents Tailwind’s default button transitions from conflicting with Motion, so Motion has full control over how the button animates.
Usage:
import { AnimatedButton } from "@/components/animated/AnimatedButton";
export default function Page() {
return (
<div className="flex gap-3">
<AnimatedButton>Default</AnimatedButton>
<AnimatedButton variant="outline">Outline</AnimatedButton>
<AnimatedButton variant="destructive" size="sm">
Delete
</AnimatedButton>
</div>
);
}
“For link buttons: If you need the button to act as a link, wrap it in Next.js
Linkrather than usingasChild”
import Link from "next/link";
<Link href="/dashboard">
<AnimatedButton>Go to Dashboard</AnimatedButton>
</Link>
Animated Loading State with Icon Swap
A static spinner replacing button text is fine. An animated icon swap with a smooth transition? That’s the kind of detail that makes your UI feel expensive.
Here’s a button that transitions from an idle state to a loading state with a satisfying layout animation:
// components/animated/LoadingButton.tsx
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Button } from "@/components/ui/button";
import { Loader2, Check } from "lucide-react";
type ButtonState = "idle" | "loading" | "success";
export function LoadingButton({ onClick }: { onClick: () => Promise<void> }) {
const [state, setState] = useState<ButtonState>("idle");
const handleClick = async () => {
setState("loading");
await onClick();
setState("success");
setTimeout(() => setState("idle"), 2000);
};
return (
<Button
onClick={handleClick}
disabled={state !== "idle"}
className="relative overflow-hidden min-w-35"
>
<AnimatePresence mode="wait">
{state === "idle" && (
<motion.span
key="idle"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
>
Submit
</motion.span>
)}
{state === "loading" && (
<motion.span
key="loading"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-2"
>
<Loader2 className="h-4 w-4 animate-spin" />
Submitting...
</motion.span>
)}
{state === "success" && (
<motion.span
key="success"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="flex items-center gap-2"
>
<Check className="h-4 w-4" />
Done!
</motion.span>
)}
</AnimatePresence>
</Button>
);
}
A few things make this work well:
-
mode="wait"onAnimatePresenceensures the current content fully exits before the next content enters, preventing both states from being visible at the same time. -
Each state has a unique key. This is required
AnimatePresenceto detect which child is entering and which is exiting. -
overflow-hiddenon the button clips any content that slides outside the button boundary during transition. -
min-w-35prevents the button from resizing as the label content changes width, a small detail that makes the whole thing feel stable.
Usage:
import { LoadingButton } from "@/components/animated/LoadingButton";
export default function Page() {
const handleSubmit = async () => {
await fetch("/api/submit", { method: "POST" });
};
return <LoadingButton onClick={handleSubmit} />;
}
Dialogs & Sheets That Feel Alive
Out of the box, Shadcn Dialog and Sheet use CSS-based transitions defined in globals.css. They work, but they’re basic fade/slide animations that you can’t customize from within React. The moment you want a spring-driven entrance or a custom exit, you’re stuck.
Here’s how to take full control.
Using forceMount + AnimatePresence to Own the Lifecycle
The key to animating Radix UI-based components (which shadcn Dialog and Sheet are built on) is understanding that Radix controls when components mount and unmount. By default, it unmounts them when closed — which means your exit animation never gets a chance to play.
The fix is forceMount. When you pass forceMount To a Radix component, it stays mounted in the DOM regardless of open state. Now you control the visibility entirely through Motion:
// components/animated/AnimatedDialog.tsx
"use client";
import { Dialog as DialogPrimitive } from "radix-ui";
import { motion, AnimatePresence, type Variants } from "motion/react";
import {
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
const overlayVariants: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
};
const contentVariants: Variants = {
hidden: { opacity: 0, scale: 0.95, y: 8 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { type: "spring", stiffness: 300, damping: 25 },
},
exit: {
opacity: 0,
scale: 0.95,
y: 8,
transition: { duration: 0.15, ease: "easeIn" },
},
};
interface AnimatedDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: React.ReactNode;
}
export function AnimatedDialog({
open,
onOpenChange,
title,
description,
children,
}: AnimatedDialogProps) {
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal forceMount>
<AnimatePresence>
{open && (
<>
{/* Animated Overlay */}
<DialogPrimitive.Overlay asChild forceMount>
<motion.div
key="overlay"
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/80"
/>
</DialogPrimitive.Overlay>
{/* Animated Content */}
<DialogPrimitive.Content asChild forceMount>
<motion.div
key="content"
variants={contentVariants}
initial="hidden"
animate="visible"
exit="exit"
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-lg bg-background p-6 shadow-lg focus:outline-none"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<DialogDescription>{description}</DialogDescription>
)}
</DialogHeader>
{children}
</motion.div>
</DialogPrimitive.Content>
</>
)}
</AnimatePresence>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
Notice the spring on enter (type: "spring") but a tween on exit (duration: 0.15, ease: "easeIn"). This is intentional; entrances should feel alive and physical, but exits should be quick and get out of the way. Users don’t watch things leave; they watch things arrive.
Usage:
import { useState } from "react";
import { AnimatedDialog } from "@/components/animated/AnimatedDialog";
import { Button } from "@/components/ui/button";
export default function Page() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open Dialog</Button>
<AnimatedDialog
open={open}
onOpenChange={setOpen}
title="Confirm Action"
description="Are you sure you want to continue?"
>
<p className="text-sm text-muted-foreground">This cannot be undone.</p>
</AnimatedDialog>
</>
);
}
Animated Sheet with Directional Awareness
The Shadcn Sheet (drawer) component is a perfect candidate for directional animation — it should slide in from whatever side it’s coming from. Here’s a reusable animated Sheet that handles all four directions:
// components/animated/AnimatedSheet.tsx
"use client";
import { Dialog as SheetPrimitive } from "radix-ui";
import { motion, AnimatePresence } from "motion/react";
import { SheetHeader, SheetTitle } from "@/components/ui/sheet";
import type { Variants } from "motion";
type SheetSide = "top" | "bottom" | "left" | "right";
const getSideClasses = (side: SheetSide): string => {
switch (side) {
case "right":
return "inset-y-0 right-0 w-3/4 max-w-sm";
case "left":
return "inset-y-0 left-0 w-3/4 max-w-sm";
case "top":
return "inset-x-0 top-0 h-auto";
case "bottom":
return "inset-x-0 bottom-0 h-auto";
}
};
const getSlideVariants = (side: SheetSide): Variants => {
const distance = "100%";
const axis = side === "left" || side === "right" ? "x" : "y";
const direction =
side === "right" || side === "bottom" ? distance : `-${distance}`;
return {
hidden: { [axis]: direction, opacity: 0 },
visible: {
[axis]: 0,
opacity: 1,
transition: { type: "spring", stiffness: 300, damping: 30 },
},
exit: {
[axis]: direction,
opacity: 0,
transition: { duration: 0.2, ease: "easeIn" },
},
} as Variants;
};
interface AnimatedSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: SheetSide;
title: string;
children: React.ReactNode;
}
export function AnimatedSheet({
open,
onOpenChange,
side = "right",
title,
children,
}: AnimatedSheetProps) {
const variants = getSlideVariants(side);
return (
<SheetPrimitive.Root open={open} onOpenChange={onOpenChange}>
<SheetPrimitive.Portal forceMount>
<AnimatePresence>
{open && (
<>
{/* Animated Overlay */}
<SheetPrimitive.Overlay asChild forceMount>
<motion.div
key="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-50 bg-black/80"
/>
</SheetPrimitive.Overlay>
{/* Animated Sheet Panel */}
<SheetPrimitive.Content asChild forceMount>
<motion.div
key="sheet"
variants={variants}
initial="hidden"
animate="visible"
exit="exit"
className={`fixed z-50 bg-background p-6 shadow-xl focus:outline-none ${getSideClasses(side)}`}
>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
{children}
</motion.div>
</SheetPrimitive.Content>
</>
)}
</AnimatePresence>
</SheetPrimitive.Portal>
</SheetPrimitive.Root>
);
}
The getSlideVariants function dynamically computes which axis to slide on and which direction to slide from based on the side prop. This means one component handles all four cases cleanly — no copy-pasted variant objects.
Usage:
import { useState } from "react";
import { AnimatedSheet } from "@/components/animated/AnimatedSheet";
import { Button } from "@/components/ui/button";
export default function Page() {
const [open, setOpen] = useState(false);
return (
<>
<Button onClick={() => setOpen(true)}>Open Sheet</Button>
<AnimatedSheet open={open} onOpenChange={setOpen} side="right" title="Settings">
<p className="text-sm text-muted-foreground mt-2">Your settings go here.</p>
</AnimatedSheet>
</>
);
}
Animated Cards, Accordion & Collapsibles
Height Animation with the layout Prop
Animating height to auto is one of those things that sounds trivial and turns out to be genuinely hard. CSS transitions don’t support height: auto. JavaScript-based solutions require measuring DOM elements manually. It’s a mess.
Motion’s layout prop solves this elegantly. When you add layout to a motion element, Motion automatically detects size changes and animates between them — including changes to height caused by content being added or removed.
Here’s a collapsible card using layout:
// components/animated/CollapsibleCard.tsx
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronDown } from "lucide-react";
export function CollapsibleCard({
title,
preview,
children,
}: {
title: string,
preview: string,
children: React.ReactNode,
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<motion.div
layout
className="overflow-hidden rounded-lg border bg-card shadow-sm py-6 w-full"
>
<CardHeader className="cursor-pointer" onClick={() => setIsOpen(!isOpen)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base">{title}</CardTitle>
<motion.div
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</motion.div>
</div>
{!isOpen && (
<p className="text-sm text-muted-foreground mt-1">{preview}</p>
)}
</CardHeader>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
<CardContent>{children}</CardContent>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
Two things to pay attention to:
-
initial={false}onAnimatePresenceprevents the content from animating in on the initial render — it only animates when the user actually toggles it. -
style={{ overflow: "hidden" }}on the animated div is essential. Without it, the content will be visible outside the collapsing container during the animation.
The chevron rotation is a small touch but makes a big difference — it gives users a clear visual cue about the direction of the interaction.
Checkout the best Shadcn Cards & Shadcn Accordian Components.
Usage:
import { CollapsibleCard } from "@/components/animated/CollapsibleCard";
export default function Page() {
return (
<CollapsibleCard
title="What is Motion?"
preview="Click to learn more about Motion..."
>
<p className="text-sm text-muted-foreground">
Motion is a production-grade animation library for React with a clean,
declarative API built on top of physics-based animations.
</p>
</CollapsibleCard>
);
}
Hover-Tilt Effect with useMotionValue + useTransform
This is one of those effects that makes people ask, “How did you do that?” — a card that tilts in 3D based on where the cursor is hovering. It feels premium without being distracting.
// components/animated/TiltCard.tsx
"use client";
import { useRef } from "react";
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function TiltCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const cardRef = useRef<HTMLDivElement>(null);
// Raw mouse position values
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
// Smooth out the raw values with a spring
const springX = useSpring(mouseX, { stiffness: 150, damping: 20 });
const springY = useSpring(mouseY, { stiffness: 150, damping: 20 });
// Transform mouse position into rotation values
const rotateX = useTransform(springY, [-0.5, 0.5], [8, -8]);
const rotateY = useTransform(springX, [-0.5, 0.5], [-8, 8]);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!cardRef.current) return;
const rect = cardRef.current.getBoundingClientRect();
// Normalize cursor position between -0.5 and 0.5
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
mouseX.set(x);
mouseY.set(y);
};
const handleMouseLeave = () => {
mouseX.set(0);
mouseY.set(0);
};
return (
<motion.div
ref={cardRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{
rotateX,
rotateY,
transformStyle: "preserve-3d",
perspective: 800,
}}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
</motion.div>
);
}
Here’s what’s happening under the hood:
-
useMotionValuecreates a reactive value that Motion can track — think of it likeuseStatebut optimized for animations (it doesn’t trigger re-renders). -
useSpringwraps that value in a spring physics simulation, so instead of the rotation snapping instantly to where your cursor is, it follows smoothly with a bit of lag, which is exactly what makes the tilt feel natural. -
useTransformmaps one range of values to another. Here we’re saying: “whenspringYis at -0.5 (top of card), rotate 8 degrees forward; when it’s at 0.5 (bottom), rotate -8 degrees back.”
The combination gives you smooth, physics-driven 3D tilt that resets elegantly when the cursor leaves.
Usage:
import { TiltCard } from "@/components/animated/TiltCard";
export default function Page() {
return (
<TiltCard title="Spring Physics">
<p className="text-sm text-muted-foreground">
Hover over this card to see the 3D tilt effect in action.
</p>
</TiltCard>
);
}
Toasts, Alerts & Feedback Components
Animating Shadcn Toasts with Custom Variants
shadcn ships with Sonner for toasts, which has its own built-in animations. But sometimes you want full control — your own toast system with custom enter/exit behavior. Here’s how to build one with Motion:
// components/animated/ToastContainer.tsx
"use client";
import { useState, useCallback } from "react";
import { motion, AnimatePresence, type Variants } from "motion/react";
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
const toastVariants: Variants = {
hidden: {
opacity: 0,
x: 60,
scale: 0.9,
},
visible: {
opacity: 1,
x: 0,
scale: 1,
transition: {
type: "spring",
stiffness: 350,
damping: 25,
},
},
exit: {
opacity: 0,
x: 60,
scale: 0.9,
transition: { duration: 0.2, ease: "easeIn" },
},
};
const icons = {
success: <CheckCircle className="h-4 w-4 text-green-500" />,
error: <AlertCircle className="h-4 w-4 text-red-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
};
const toastColors: Record<ToastType, string> = {
success:
"border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800",
error: "border-red-200 bg-red-50 dark:bg-red-950 dark:border-red-800",
info: "border-blue-200 bg-blue-50 dark:bg-blue-950 dark:border-blue-800",
};
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = useCallback((message: string, type: ToastType = "info") => {
const id = crypto.randomUUID();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, addToast, removeToast };
}
export function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[];
onRemove: (id: string) => void;
}) {
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-80">
<AnimatePresence mode="popLayout">
{toasts.map((toast) => (
<motion.div
key={toast.id}
layout
variants={toastVariants}
initial="hidden"
animate="visible"
exit="exit"
className={`flex items-start gap-3 rounded-lg border p-4 shadow-md ${toastColors[toast.type]}`}
>
<span className="mt-0.5 shrink-0">{icons[toast.type]}</span>
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={() => onRemove(toast.id)}
className="shrink-0 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
Two Motion features worth highlighting here:
-
mode="popLayout"onAnimatePresenceis different frommode="wait". Instead of waiting for one element to exit before the next enters,popLayoutimmediately removes the exiting element from the layout flow and lets the remaining elements reflow smoothly. For a toast stack where items can be dismissed while others are animating in, this feels much more natural. -
layouton each toast item ensures that when one toast is removed, and the others shift up, that shift is animated rather than instant. Without it, toasts would teleport to their new positions. With it, they slide smoothly.
Usage:
"use client";
import { useToast, ToastContainer } from "@/components/animated/ToastContainer";
import { Button } from "@/components/ui/button";
export default function Page() {
const { toasts, addToast, removeToast } = useToast();
return (
<>
<div className="flex gap-2">
<Button onClick={() => addToast("File saved successfully!", "success")}>
Success
</Button>
<Button onClick={() => addToast("Something went wrong.", "error")} variant="destructive">
Error
</Button>
</div>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</>
);
}
If you require catchy alerts, Shadcn Alert components are the best to use.
Shake Animation for Form Validation Errors
Nothing communicates “wrong input” more clearly than a quick shake animation. Here’s a reusable hook that triggers a shake on any element:
// components/animated/ShakeField.tsx
"use client";
import { useState } from "react";
import {
AnimatePresence,
motion,
useAnimation,
type Transition,
} from "motion/react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const shakeKeyframes = {
x: [0, -8, 8, -8, 8, -4, 4, 0],
};
const shakeTransition: Transition = {
duration: 0.5,
ease: "easeInOut",
};
export function ValidatedInput({
label,
validate,
}: {
label: string,
validate: (value: string) => string | null,
}) {
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
const controls = useAnimation();
const handleBlur = async () => {
const errorMessage = validate(value);
if (errorMessage) {
setError(errorMessage);
await controls.start(shakeKeyframes, shakeTransition);
} else {
setError(null);
}
};
return (
<div className="space-y-1.5">
<Label htmlFor={label}>{label}</Label>
<motion.div animate={controls}>
<Input
id={label}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleBlur}
className={error ? "border-red-500 focus-visible:ring-red-500" : ""}
aria-invalid={!!error}
aria-describedby={error ? `${label}-error` : undefined}
/>
</motion.div>
<AnimatePresence>
{error && (
<motion.p
id={`${label}-error`}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="text-xs text-red-500"
>
{error}
</motion.p>
)}
</AnimatePresence>
</div>
);
}
-
useAnimation()returns an animation controls object that lets you trigger animations imperatively — perfect for cases like this where the animation is in response to an async event (validation) rather than a state change. -
The
shakeKeyframesobject uses an array of values instead of a start/end pair — Motion interpolates through each value in sequence, creating the back-and-forth shake effect. The values[0, -8, 8, -8, 8, -4, 4, 0]are intentionally asymmetric and decreasing — it mimics how a real physical shake loses energy over time.
And notice the error message itself gets an entrance animation - it slides down from above and fades in. That tiny detail makes error states feel considered rather than abrupt.
Usage:
import { ValidatedInput } from "@/components/animated/ShakeField";
export default function Page() {
return (
<ValidatedInput
label="Email"
validate={(value) => {
if (!value.includes("@")) return "Please enter a valid email address.";
return null;
}}
/>
);
}
Part 3: Intermediate Patterns
Page & Route Transitions in Next.js App Router
Route transitions are one of the most requested animation features in any React app. They’re also one of the trickiest to implement correctly — especially in Next.js App Router, which changed how layouts and pages work compared to the Pages Router.
Let’s talk about why it’s hard, and then build something that actually works.
Why Route Transitions Are Hard in App Router
In the old Pages Router, you had a single _app.tsx where every page rendered. Wrapping it in AnimatePresence was straightforward — the key change on every route, Motion detected the old component exiting and the new one entering, done.
App Router broke this pattern. Pages now render inside nested layouts that persist across routes. React doesn’t unmount and remount the layout on navigation — only the changed segment re-renders. This means:
-
There’s no single place to put
AnimatePresenceThat works for all routes -
The
keytrick doesn’t work the same way -
Exit animations are particularly difficult because Next.js starts rendering the new page before the old one has finished exiting
The honest truth: full exit animations between routes are still not cleanly solved in App Router as of early 2026. You can get entrance animations working reliably, and with some workarounds you can get a passable exit — but the seamless page-to-page transition you might be imagining requires either a third-party library like next-view-transitions or accepting some limitations.
Here’s what works well today.
Layout-Level AnimatePresence Setup
The most reliable approach is to add entrance animations at the layout level, using the pathname as a key to trigger re-animation on route change:
// app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: {
children: React.ReactNode,
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// app/providers.tsx
"use client";
import { AnimatePresence } from "motion/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <AnimatePresence mode="wait">{children}</AnimatePresence>;
}
Now, create a reusable PageTransition wrapper that you use inside each page:
// components/animated/PageTransition.tsx
"use client";
import { motion, type Variants } from "motion/react";
import { usePathname } from "next/navigation";
const pageVariants: Variants = {
hidden: { opacity: 0, y: 16 },
visible: {
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 260,
damping: 24,
staggerChildren: 0.05,
},
},
exit: {
opacity: 0,
y: -8,
transition: { duration: 0.15, ease: "easeIn" },
},
};
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<motion.main
key={pathname}
variants={pageVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{children}
</motion.main>
);
}
Use it on each page like this:
// app/dashboard/page.tsx
import { PageTransition } from "@/components/animated/PageTransition";
export default function DashboardPage() {
return (
<PageTransition>
<h1>Dashboard</h1>
{/* page content */}
</PageTransition>
);
}
The key={pathname} is what makes this work — when the route changes, pathname changes, React sees a new component with a different key, and AnimatePresence handles the enter/exit sequence.
“Gotcha: App Router layouts are server components by default.
AnimatePresenceis a client component, which is why we isolate it insideproviders.tsxwith"use client". Never add"use client"to your root layout — it forces your entire app to be client-rendered and kills the performance benefits of RSC.”
Usage:
// app/about/page.tsx
import { PageTransition } from "@/components/animated/PageTransition";
export default function AboutPage() {
return (
<PageTransition>
<h1>About</h1>
{/* page content */}
</PageTransition>
);
}
Wrap every page the same way — the transition plays automatically on each route change.
Scroll-Driven Animations with whileInView
Scroll animations are everywhere — and when done well, they make content feel like it’s being revealed intentionally rather than just dumped on the screen. When done poorly, they’re just annoying delays between the user and the content they want.
The rule I follow: scroll animations should reveal, not obstruct. If the animation delays the user from reading something they’ve scrolled to see, it’s too slow or too aggressive.
Viewport-Triggered Entrance Animations
whileInView is Motion’s simplest scroll animation tool. It animates an element when it enters the viewport, and optionally reverses when it leaves:
// components/animated/RevealOnScroll.tsx
"use client";
import { motion } from "motion/react";
interface RevealProps {
children: React.ReactNode;
delay?: number;
className?: string;
}
export function RevealOnScroll({
children,
delay = 0,
className,
}: RevealProps) {
return (
<motion.div
initial={{ opacity: 0, y: 32 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{
type: "spring",
stiffness: 260,
damping: 24,
delay,
}}
className={className}
>
{children}
</motion.div>
);
}
Two viewport options worth understanding:
-
once: true— the animation only plays once. After it’s played, Motion stops observing the element. This is almost always what you want for entrance animations. Replaying the animation every time the user scrolls past is disorienting. -
margin: "-80px"— shrinks the viewport by 80px before triggering. This means the animation triggers when the element is 80px into the viewport rather than right at the edge — it feels more intentional and less like it fired too early.
Use it to stagger a list of cards on a landing page:
// app/page.tsx
import { RevealOnScroll } from "@/components/animated/RevealOnScroll";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const features = [
{ title: "Fast", description: "Optimized for performance" },
{ title: "Accessible", description: "Built with a11y in mind" },
{ title: "Beautiful", description: "Polished UI out of the box" },
];
export default function HomePage() {
return (
<section className="grid grid-cols-1 md:grid-cols-3 gap-6 py-24">
{features.map((feature, i) => (
<RevealOnScroll key={feature.title} delay={i * 0.1}>
<Card>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{feature.description}</p>
</CardContent>
</Card>
</RevealOnScroll>
))}
</section>
);
}
The stagger here is manual — each card gets a delay of i * 0.1. This is different from using staggerChildren in variants (which we’ll cover in Part 4) because whileInView fires independently per element as it enters the viewport. If all three cards enter the viewport at once, they’ll stagger. If the user scrolls slowly and they enter one at a time, each just plays immediately — which is also fine.
useScroll + useTransform for Parallax and Scroll Progress
whileInView is great for triggered animations, but for animations that are continuously tied to scroll position — parallax effects, progress bars, sticky header transforms — you need useScroll and useTransform.
Scroll Progress Indicator:
// components/animated/ScrollProgress.tsx
"use client";
import { motion, useScroll, useSpring } from "motion/react";
export function ScrollProgress() {
const { scrollYProgress } = useScroll();
// Smooth out the scroll progress with a spring
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
return (
<motion.div
style={{ scaleX, transformOrigin: "left" }}
className="fixed top-0 left-0 right-0 h-1 bg-primary z-50"
/>
);
}
useScroll returns scrollYProgress — a MotionValue that goes from 0 to 1 as the user scrolls from top to bottom. We pipe it through useSpring to smooth out any jank from rapid scroll events, then use it as the scaleX of a full-width bar. The transformOrigin: "left" makes it grow from the left edge rather than the center.
Usage:
// app/layout.tsx
import { ScrollProgress } from "@/components/animated/ScrollProgress";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ScrollProgress />
{children}
</body>
</html>
);
}
Parallax Hero Section:
// components/animated/ParallaxHero.tsx
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
export function ParallaxHero() {
const ref = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start start", "end start"],
});
// As we scroll through the hero, move content up at half speed (parallax)
const y = useTransform(scrollYProgress, [0, 1], ["0%", "30%"]);
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0]);
return (
<div ref={ref} className="relative h-screen overflow-hidden">
<motion.div
style={{ y, opacity }}
className="absolute inset-0 flex flex-col items-center justify-center text-center px-4"
>
<h1 className="text-6xl font-bold tracking-tight">
Build beautiful UIs
</h1>
<p className="mt-4 text-xl text-muted-foreground max-w-lg">
With shadcn/ui and Motion, animation is part of the design language.
</p>
</motion.div>
</div>
);
}
The offset: ["start start", "end start"] tells Motion which portion of the scroll to track:
-
"start start"— begin tracking when the top of the element reaches the top of the viewport -
"end start"— stop tracking when the bottom of the element reaches the top of the viewport
In plain English: track the scroll while the hero is visible on screen.
useTransformthen maps that 0–1 scroll progress to specific values — content moves up 30% and fades out as you scroll past. The effect makes the hero feel like it has depth.
Animated Sticky Header:
// components/animated/AnimatedHeader.tsx
"use client";
import { motion, useScroll, useMotionValueEvent } from "motion/react";
import { useState } from "react";
export function AnimatedHeader() {
const { scrollY } = useScroll();
const [isScrolled, setIsScrolled] = useState(false);
// Listen to scroll position changes
useMotionValueEvent(scrollY, "change", (latest) => {
setIsScrolled(latest > 60);
});
return (
<motion.header
animate={{
backgroundColor: isScrolled ? "var(--background)" : "transparent",
boxShadow: isScrolled ? "0 1px 3px rgba(0,0,0,0.1)" : "none",
backdropFilter: isScrolled ? "blur(12px)" : "blur(0px)",
}}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="fixed top-0 left-0 right-0 z-40 px-6 py-4"
>
<nav className="flex items-center justify-between max-w-6xl mx-auto">
<span className="font-semibold">Logo</span>
<div className="flex gap-6">
<a href="#" className="text-sm hover:text-primary transition-colors">
Features
</a>
<a href="#" className="text-sm hover:text-primary transition-colors">
Pricing
</a>
<a href="#" className="text-sm hover:text-primary transition-colors">
Docs
</a>
</div>
</nav>
</motion.header>
);
}
useMotionValueEvent is the right way to react to MotionValue changes inside components — it’s like addEventListener for motion values, but cleaned up automatically when the component unmounts. We use it here to flip a boolean that drives the header’s background and shadow.
“Performance tip: Animating
backgroundColor,boxShadow, andbackdropFiltertriggers paint — not just compositing. For most headers this is fine since it only changes once on scroll past the threshold. But for continuously scroll-linked properties, stick totransformandopacitywhich run entirely on the GPU.”
Usage:
// app/layout.tsx
import { AnimatedHeader } from "@/components/animated/AnimatedHeader";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<AnimatedHeader />
<main>{children}</main>
</body>
</html>
);
}
Gesture-Driven UI: Drag, Pan & Swipe
Drag interactions are where Motion really shines compared to CSS animations. Building a drag interaction with raw pointer events is dozens of lines of error-prone code — with Motion, it’s a prop.
drag Constraints and Elastic Boundaries
The simplest draggable element:
<motion.div drag>
Drag me anywhere
</motion.div>
That’s it. But unconstrained dragging is rarely what you want. You’ll usually want to limit where the element can go:
// Constrain to a bounding box
<motion.div
drag
dragConstraints={{ top: -50, left: -50, right: 50, bottom: 50 }}
dragElastic={0.2} // how much it can go past constraints (0 = rigid, 1 = fully elastic)
dragTransition={{ bounceStiffness: 300, bounceDamping: 20 }}
>
Constrained drag
</motion.div>
dragElastic is a subtle but important property. Setting it to 0 makes the constraint feel like a hard wall — jarring and unnatural. A value of 0.1 to 0.3 lets the element stretch slightly past the boundary before snapping back, which gives the interaction a satisfying physical feel.
You can also constrain to the bounds of a parent element using a ref:
const containerRef = useRef(null);
<div ref={containerRef} className="relative w-80 h-80 border rounded-lg">
<motion.div
drag
dragConstraints={containerRef}
className="absolute w-16 h-16 bg-primary rounded-lg cursor-grab active:cursor-grabbing"
/>
</div>
Building a Swipeable Card Stack
This is the kind of component people spend hours building from scratch. With Motion it’s surprisingly approachable. Here’s a Tinder-style swipeable card stack:
// components/animated/SwipeableCards.tsx
"use client";
import { useState } from "react";
import { motion, useMotionValue, useTransform, AnimatePresence } from "motion/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface CardData {
id: number;
title: string;
description: string;
color: string;
}
const cards: CardData[] = [
{ id: 1, title: "Card One", description: "Swipe me left or right", color: "bg-blue-100" },
{ id: 2, title: "Card Two", description: "Keep going...", color: "bg-purple-100" },
{ id: 3, title: "Card Three", description: "Last one!", color: "bg-green-100" },
];
function SwipeCard({
card,
onSwipe,
isTop,
}: {
card: CardData;
onSwipe: (id: number, direction: "left" | "right") => void;
isTop: boolean;
}) {
const x = useMotionValue(0);
// Rotate card as it's dragged
const rotate = useTransform(x, [-200, 200], [-25, 25]);
// Show like/dislike indicators
const likeOpacity = useTransform(x, [20, 100], [0, 1]);
const dislikeOpacity = useTransform(x, [-100, -20], [1, 0]);
const handleDragEnd = () => {
const xValue = x.get();
if (Math.abs(xValue) > 100) {
onSwipe(card.id, xValue > 0 ? "right" : "left");
}
};
return (
<motion.div
style={{ x, rotate, position: "absolute", width: "100%" }}
drag={isTop ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragEnd={handleDragEnd}
animate={{ scale: isTop ? 1 : 0.95 }}
exit={{
x: x.get() > 0 ? 300 : -300,
opacity: 0,
transition: { duration: 0.3 },
}}
className="cursor-grab active:cursor-grabbing"
>
<Card className={`${card.color} border-none shadow-lg`}>
{/* Like indicator */}
<motion.div
style={{ opacity: likeOpacity }}
className="absolute top-4 left-4 -rotate-15 border-4 border-green-500 text-green-500 font-bold text-xl px-2 py-1 rounded"
>
LIKE
</motion.div>
{/* Dislike indicator */}
<motion.div
style={{ opacity: dislikeOpacity }}
className="absolute top-4 right-4 rotate-15 border-4 border-red-500 text-red-500 font-bold text-xl px-2 py-1 rounded"
>
NOPE
</motion.div>
<CardHeader>
<CardTitle>{card.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
</motion.div>
);
}
export function SwipeableCards() {
const [remaining, setRemaining] = useState(cards);
const handleSwipe = (id: number, direction: "left" | "right") => {
console.log(`Swiped ${direction}:`, id);
setRemaining((prev) => prev.filter((c) => c.id !== id));
};
return (
<div className="relative w-80 h-64 mx-auto">
<AnimatePresence>
{remaining
.slice()
.reverse()
.map((card, i) => (
<SwipeCard
key={card.id}
card={card}
onSwipe={handleSwipe}
isTop={i === remaining.length - 1}
/>
))}
</AnimatePresence>
{remaining.length === 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="absolute inset-0 flex items-center justify-center"
>
<p className="text-muted-foreground font-medium">All caught up! 🎉</p>
</motion.div>
)}
</div>
);
}
Let’s unpack the key techniques here:
dragConstraints={{ left: 0, right: 0 }} with dragElastic={1}
- This is a counterintuitive pattern. The constraints are set to 0 (no movement allowed), but
dragElasticat 1 means the element can move freely beyond constraints. The result: the card follows the cursor, but when released, it snaps back to the center (unless we intercept it inonDragEnd).
useTransform For indicators,
- the LIKE and NOPE labels are tied directly to the
xMotionValue. NouseState, no event handlers. As the card moves right, LIKE fades in. As it moves left, NOPE fades in. This is Motion’s reactive value system working at its best — smooth, zero-lag, zero re-renders.
isTop prop
- Only the top card is draggable. Cards underneath just sit there looking pretty, scaled down slightly to create depth. This prevents accidental drag interactions on stacked cards.
.slice().reverse()
- We reverse the array before rendering, so the first card renders last (on top in the DOM stack). Since absolute positioning means later elements sit on top, this puts card index 0 at the top of the visual stack.
Usage:
import { SwipeableCards } from "@/components/animated/SwipeableCards";
export default function Page() {
return (
<div className="flex items-center justify-center min-h-screen">
<SwipeableCards />
</div>
);
}
useDragControls for Custom Drag Handles:
Sometimes you want to initiate a drag from a specific handle element rather than the entire component — think a modal that can be dragged by its title bar, or a sortable list item with a grip icon:
// components/animated/DraggablePanel.tsx
"use client";
import { motion, useDragControls } from "motion/react";
import { GripHorizontal } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function DraggablePanel() {
const dragControls = useDragControls();
return (
<motion.div
drag
dragControls={dragControls}
dragListener={false} // disable default drag (whole element)
dragMomentum={false} // no slide after release
dragConstraints={{ top: 0, left: 0, right: 300, bottom: 300 }}
className="w-72"
>
<Card>
<CardHeader
onPointerDown={(e) => dragControls.start(e)}
className="cursor-grab active:cursor-grabbing select-none"
>
<div className="flex items-center justify-between">
<CardTitle className="text-sm">Draggable Panel</CardTitle>
<GripHorizontal className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Only draggable from the title bar above.
</p>
</CardContent>
</Card>
</motion.div>
);
}
dragListener={false} disables the default behavior of listening for drag on the whole element. dragControls.start(e) initiates the drag from the pointer event on the handle. The two together give you a component that only responds to drags starting from the header — exactly like a desktop window.
“Tip: Always add
user-select: none(or Tailwind’sselect-none) to drag handles. Without it, dragging will select text instead of moving the element — a frustrating experience for users.”
Usage:
Part 4: Advanced Techniques
Orchestrating Complex Sequences with Variants
We touched on variants back in Part 1 - named animation states that replace inline objects. But variants have a superpower we haven’t talked about yet: they propagate automatically through the component tree.
This is what separates a developer who uses Motion from one who understands Motion.
Parent-Child Variant Propagation
When a parent motion element has initial and animate set to variant names, every child motion element that defines those same variant names will automatically inherit and respond to the parent’s state - no prop drilling required.
// components/animated/StaggeredList.tsx
"use client";
import { motion, type Variants } from "motion/react";
// Parent defines the orchestration
// Children just define what they look like in each state
// Motion wires them together automatically
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1, // each child animates 100ms after the previous
delayChildren: 0.2, // wait 200ms before starting the first child
when: "beforeChildren", // parent fully animates before children start
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: "spring", stiffness: 300, damping: 24 },
},
};
export function StaggeredList({ items }: { items: string[] }) {
return (
<motion.ul variants={containerVariants} initial="hidden" animate="visible">
{items.map((item) => (
// No initial/animate needed — inherited from parent
<motion.li key={item} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
}
The ul has initial="hidden" and animate="visible". Each li has variants={itemVariants} — and that’s it. Motion sees that the parent is controlling the animation state and propagates "hidden" → "visible" down to every child, respecting the staggerChildren timing.
This is clean in a way that’s hard to overstate. You can add or remove items from the list without touching the animation code. You can nest this component inside another staggered list, and it just works.
staggerChildren, delayChildren, when: "beforeChildren"
These three transition properties inside a variant are what give you orchestration control:
const menuVariants = {
closed: {
opacity: 0,
height: 0,
transition: {
when: "afterChildren", // children animate out first, then parent collapses
staggerChildren: 0.05,
staggerDirection: -1, // stagger in reverse order on exit (last item exits first)
},
},
open: {
opacity: 1,
height: "auto",
transition: {
when: "beforeChildren", // parent opens first, then children animate in
staggerChildren: 0.07,
delayChildren: 0.1,
},
},
};
-
staggerChildren— time in seconds between each child’s animation start -
delayChildren— delay before the first child starts (after the parent has animated) -
when: "beforeChildren"— parent completes its animation before any child starts -
when: "afterChildren"— all children complete before the parent animates -
staggerDirection: -1— reverses the stagger order (useful for exit animations where you want the last item to exit first)
Building an Animated Menu That Sequences Its Children
Let’s put this all together into a real component — a dropdown navigation menu with a satisfying open/close sequence:
// components/animated/AnimatedMenu.tsx
"use client";
import { useState } from "react";
import { motion, AnimatePresence, type Variants } from "motion/react";
import { Button } from "@/components/ui/button";
import { ChevronDown, Home, Settings, Users, BarChart } from "lucide-react";
const menuItems = [
{ label: "Dashboard", icon: Home, href: "#" },
{ label: "Analytics", icon: BarChart, href: "#" },
{ label: "Team", icon: Users, href: "#" },
{ label: "Settings", icon: Settings, href: "#" },
];
const menuVariants: Variants = {
closed: {
opacity: 0,
scale: 0.95,
y: -8,
transition: {
when: "afterChildren",
staggerChildren: 0.04,
staggerDirection: -1,
duration: 0.2,
},
},
open: {
opacity: 1,
scale: 1,
y: 0,
transition: {
when: "beforeChildren",
staggerChildren: 0.06,
delayChildren: 0.05,
type: "spring",
stiffness: 300,
damping: 24,
},
},
};
const itemVariants: Variants = {
closed: {
opacity: 0,
x: -8,
transition: { duration: 0.15 },
},
open: {
opacity: 1,
x: 0,
transition: { type: "spring", stiffness: 300, damping: 24 },
},
};
export function AnimatedMenu() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative inline-block">
<Button
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2"
>
Menu
<motion.span
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
>
<ChevronDown className="h-4 w-4" />
</motion.span>
</Button>
<AnimatePresence>
{isOpen && (
<motion.div
variants={menuVariants}
initial="closed"
animate="open"
exit="closed"
className="absolute top-full mt-2 left-0 w-52 rounded-lg border bg-popover shadow-md overflow-hidden z-50"
>
{menuItems.map((item) => (
<motion.a
key={item.label}
href={item.href}
variants={itemVariants}
whileHover={{
backgroundColor: "hsl(var(--accent))",
x: 4,
transition: { duration: 0.15 },
}}
className="flex items-center gap-3 px-4 py-2.5 text-sm text-popover-foreground cursor-pointer"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.label}
</motion.a>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Notice what the exit animation does here - when: "afterChildren" with staggerDirection: -1 means when the menu closes, the bottom item exits first, then each item above it, and finally the container collapses. It’s the reverse of the open sequence, which gives the whole interaction a polished, intentional feel.
The whileHover on each item adds a subtle x: 4 nudge - a micro-interaction that makes each item feel responsive without being distracting. Combined with the background color change, it’s a hover state that feels crafted.
“Personal tip: Keep
staggerChildrenvalues small - between0.04and0.1seconds. Anything larger starts to feel like you’re making the user wait. The stagger should be felt as energy and rhythm, not noticed as a delay.”
Usage:
import { AnimatedMenu } from "@/components/animated/AnimatedMenu";
export default function Page() {
return (
<nav className="flex items-center gap-4 p-4">
<span className="font-semibold">Logo</span>
<AnimatedMenu />
</nav>
);
}
Motion Values & The Imperative API
Everything we’ve covered so far uses Motion’s declarative API - you describe states, Motion handles transitions. But Motion has a second mode: reactive values and imperative control that let you drive animations from any data source, not just React state.
This is where Motion becomes genuinely powerful for custom interactions.
useMotionValue, useTransform, useSpring Explained
You’ve seen these used in the tilt-card and swipe-card examples. Let’s understand them properly.
useMotionValue(initialValue) creates a special value that:
-
Can be updated without triggering React re-renders
-
Can be passed to
motionelements as astyleprop -
Can be subscribed to by other motion values via
useTransform
const x = useMotionValue(0);
// Attach to an element
<motion.div style={{ x }} />
// Update it from anywhere — no re-render
x.set(100);
// Read it
console.log(x.get()); // 100
// Subscribe to changes
x.on("change", (latest) => console.log(latest));
useTransform(value, input, output) creates a derived motion value — it maps one range to another reactively:
const x = useMotionValue(0);
// When x is 0, opacity is 1. When x is 100, opacity is 0.
const opacity = useTransform(x, [0, 100], [1, 0]);
// Non-linear mapping with multiple points
const backgroundColor = useTransform(
x,
[0, 50, 100],
["#3b82f6", "#8b5cf6", "#ec4899"]
);
// Function-based transform for custom logic
const display = useTransform(x, (v) => (v < 50 ? "block" : "none"));
useSpring(value, config) wraps any motion value in spring physics — it follows the source value but with lag and bounce:
const mouseX = useMotionValue(0);
// springX follows mouseX but with spring physics
const springX = useSpring(mouseX, {
stiffness: 150,
damping: 15,
mass: 0.5,
});
The combination of these three is what lets you build interactions that feel connected to physics rather than just CSS.
Building a Custom Cursor That Follows the Pointer
This is a classic Motion demo that shows the reactive value system working in real-time:
// components/animated/CustomCursor.tsx
"use client";
import { useEffect } from "react";
import { motion, useMotionValue, useSpring } from "motion/react";
export function CustomCursor() {
const mouseX = useMotionValue(-100);
const mouseY = useMotionValue(-100);
// Outer ring follows with lag
const springX = useSpring(mouseX, { stiffness: 100, damping: 20 });
const springY = useSpring(mouseY, { stiffness: 100, damping: 20 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mouseX.set(e.clientX);
mouseY.set(e.clientY);
};
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}, [mouseX, mouseY]);
return (
<>
{/* Inner dot — snaps instantly */}
<motion.div
style={{
x: mouseX,
y: mouseY,
translateX: "-50%",
translateY: "-50%",
}}
className="fixed top-0 left-0 w-2 h-2 bg-primary rounded-full pointer-events-none z-9999"
/>
{/* Outer ring — follows with spring lag */}
<motion.div
style={{
x: springX,
y: springY,
translateX: "-50%",
translateY: "-50%",
}}
className="fixed top-0 left-0 w-8 h-8 border-2 border-primary rounded-full pointer-events-none z-9998 opacity-60"
/>
</>
);
}
The inner dot uses mouseX / mouseY directly — zero lag, pixel-perfect tracking. The outer ring uses springX / springY — it follows behind with spring physics. The visual separation between them creates a sense of weight and depth.
Notice we use useEffect to set motion values rather than React state. This is intentional — mouseX.set() doesn’t trigger a re-render. The component renders once and then runs entirely in Motion’s animation loop for the rest of its life. This is what makes custom cursors performant even on lower-end devices.
Usage:
// app/layout.tsx
import { CustomCursor } from "@/components/animated/CustomCursor";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<CustomCursor />
{children}
</body>
</html>
);
}
You’ll also want to hide the native cursor via CSS:
/* app/globals.css */
* {
cursor: none !important;
}
animate() Function for Programmatic Control Outside React Render
Sometimes you need to trigger animations from outside a React component — from a utility function, a global event handler, or in response to something that doesn’t fit the declarative model. The animate() function is Motion’s imperative escape hatch:
import { animate } from "motion";
// Animate a DOM element directly
const element = document.getElementById("my-element");
animate(element, { opacity: 0, y: -20 }, { duration: 0.3 });
// Animate a single value over time
const stop = animate(0, 100, {
duration: 2,
onUpdate: (value) => {
document.title = `Loading: ${Math.round(value)}%`;
},
});
// Stop it early
stop();
- A practical use case — an animated number counter that runs once when the component mounts, without needing any React state for the intermediate values:
// components/animated/CountUp.tsx
"use client";
import { useEffect, useRef } from "react";
import { animate } from "motion";
export function CountUp({
from = 0,
to,
duration = 1.5,
className,
}: {
from?: number;
to: number;
duration?: number;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!ref.current) return;
const controls = animate(from, to, {
duration,
ease: [0.16, 1, 0.3, 1], // custom ease — fast start, slow finish
onUpdate: (value) => {
if (ref.current) {
ref.current.textContent = Math.round(value).toLocaleString();
}
},
});
return () => controls.stop();
}, [from, to, duration]);
return <span ref={ref} className={className}>{from.toLocaleString()}</span>;
}
- Usage:
<CountUp to={24891} duration={2} className="text-4xl font-bold tabular-nums" />
-
The
animate()function updates the DOM directly viatextContent— no state, no re-renders, silky smooth. Thetabular-numsclass prevents the number from jumping around as digit widths change during the count-up. Small detail, big difference. -
The custom ease
[0.16, 1, 0.3, 1]is an “expo out” curve — it starts fast and decelerates dramatically near the end. This makes the counter feel like it’s “landing” on the final value rather than just stopping.
Usage:
import { CountUp } from "@/components/animated/CountUp";
export default function Page() {
return (
<div className="flex gap-8">
<div>
<CountUp to={12489} duration={2} className="text-4xl font-bold tabular-nums" />
<p className="text-sm text-muted-foreground mt-1">Total Users</p>
</div>
<div>
<CountUp to={98} duration={1.5} className="text-4xl font-bold tabular-nums" />
<p className="text-sm text-muted-foreground mt-1">Uptime %</p>
</div>
</div>
);
}
Part 5: Real-World Component Builds
Building an Animated Like Button
We’ve covered a lot of ground. Now let’s build something that pulls it all together — a Like button with a heart burst effect that feels genuinely delightful to press.
This component uses useMotionValue, useTransform, AnimatePresence, and keyframe animations all working together. It’s the kind of micro-interaction that separates apps people enjoy using from apps they merely tolerate.
What We’re Building
When the user clicks:
-
The heart icon scales up with a spring bounce
-
Particle bursts fly outward in multiple directions
-
A ripple ring expands and fades
-
The like count increments with a slide animation
-
Clicking again reverses everything with a deflated animation
The Heart Burst Effect
// components/animated/LikeButton.tsx
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Heart } from "lucide-react";
// Particle positions — angles around the heart in degrees
const PARTICLES = [0, 45, 90, 135, 180, 225, 270, 315];
function Particle({ angle, isLiked }: { angle: number, isLiked: boolean }) {
const radian = (angle * Math.PI) / 180;
const distance = 28;
return (
<motion.div
initial={{ opacity: 0, scale: 0, x: 0, y: 0 }}
animate={
isLiked
? {
opacity: [0, 1, 1, 0],
scale: [0, 1, 0.8, 0],
x: Math.cos(radian) * distance,
y: Math.sin(radian) * distance,
}
: { opacity: 0, scale: 0, x: 0, y: 0 }
}
transition={
isLiked
? { duration: 0.5, ease: "easeOut", times: [0, 0.2, 0.7, 1] }
: { duration: 0.2 }
}
className="absolute top-1/2 left-1/2 w-2 h-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-rose-400 pointer-events-none"
/>
);
}
function RippleRing({ isLiked }: { isLiked: boolean }) {
return (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={
isLiked
? { scale: [0.5, 1.8], opacity: [0.6, 0] }
: { scale: 0.5, opacity: 0 }
}
transition={
isLiked ? { duration: 0.4, ease: "easeOut" } : { duration: 0 }
}
className="absolute inset-0 rounded-full border-2 border-rose-400 pointer-events-none"
/>
);
}
export function LikeButton({ initialCount = 0 }: { initialCount?: number }) {
const [isLiked, setIsLiked] = useState(false);
const [count, setCount] = useState(initialCount);
const [particleKey, setParticleKey] = useState(0);
const handleClick = () => {
const nextLiked = !isLiked;
setIsLiked(nextLiked);
setCount((prev) => (nextLiked ? prev + 1 : prev - 1));
// Re-mount particles on every like click so animation replays
if (nextLiked) {
setParticleKey((k) => k + 1);
}
};
return (
<div className="flex items-center gap-2">
<button
onClick={handleClick}
className="relative flex items-center justify-center w-10 h-10 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
aria-label={isLiked ? "Unlike" : "Like"}
aria-pressed={isLiked}
>
{/* Ripple ring */}
<AnimatePresence>
{isLiked && (
<RippleRing key={`ripple-${particleKey}`} isLiked={isLiked} />
)}
</AnimatePresence>
{/* Particles */}
{PARTICLES.map((angle) => (
<Particle
key={`${particleKey}-${angle}`}
angle={angle}
isLiked={isLiked}
/>
))}
{/* Heart icon */}
<motion.div
animate={
isLiked
? { scale: [1, 1.4, 0.9, 1.15, 1], rotate: [0, -8, 8, -3, 0] }
: { scale: [1, 0.8, 1], rotate: 0 }
}
transition={
isLiked
? {
type: "tween",
duration: 0.4,
times: [0, 0.2, 0.4, 0.6, 1],
ease: "easeOut",
}
: { type: "tween", duration: 0.2, ease: "easeOut" }
}
>
<Heart
className={`h-5 w-5 transition-colors duration-150 ${
isLiked
? "fill-rose-500 text-rose-500"
: "fill-transparent text-slate-400"
}`}
/>
</motion.div>
</button>
{/* Animated count */}
<div className="relative h-5 w-8 overflow-hidden">
<AnimatePresence mode="popLayout">
<motion.span
key={count}
initial={{ opacity: 0, y: isLiked ? 12 : -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: isLiked ? -12 : 12 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
className="absolute inset-0 flex items-center text-sm font-medium text-muted-foreground"
>
{count}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}
Let’s walk through the interesting decisions made here:
Keyframe arrays for the heart bounce
- Instead of a simple
scale: 1.3, the heart goes through[1, 1.4, 0.9, 1.15, 1]. This mimics the feel of something being squished and then bouncing back — more organic than a simple scale-up-scale-down. Thetimesarray[0, 0.2, 0.4, 0.6, 1]controls when each keyframe is reached as a fraction of the total duration.
particleKey trick
- Particles are stateless components that run their animation once on mount. To replay the animation on every like click, we increment a key value that forces React to unmount and remount the particles. This is a legitimate pattern for “fire and forget” animations that need to repeat.
Directional count animation
- The count number slides up when liking (positive direction) and slides down when unliking (negative direction). This directional awareness makes the increment feel intentional — the number moves in the direction that matches the action.
**aria-pressed**
- This is important. The button uses a native
<button>element with proper ARIA attributes. Motion handles the visuals; HTML handles the accessibility. Never sacrifice one for the other.
Usage:
import { LikeButton } from "@/components/animated/LikeButton";
export default function Page() {
return <LikeButton initialCount={142} />;
}
Putting the Like Button in Context
Here’s how it looks integrated into a shadcn Card, which is how you’d actually use it in a real app:
// Example usage in a feed card
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { LikeButton } from "@/components/animated/LikeButton";
import { Button } from "@/components/ui/button";
import { MessageCircle, Share2 } from "lucide-react";
export function FeedCard() {
return (
<Card className="max-w-md">
<CardHeader className="flex flex-row items-center gap-3 pb-3">
<Avatar>
<AvatarImage src="/avatar.jpg" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<div>
<p className="font-semibold text-sm">Jane Doe</p>
<p className="text-xs text-muted-foreground">2 hours ago</p>
</div>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed">
Just shipped an animated component library built with shadcn/ui
and Motion. The Like button alone took three iterations to get right,
but it was absolutely worth it. ✨
</p>
</CardContent>
<CardFooter className="flex items-center gap-1 pt-3 border-t">
<LikeButton initialCount={142} />
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground">
<MessageCircle className="h-4 w-4" />
<span className="text-xs">24</span>
</Button>
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground ml-auto">
<Share2 className="h-4 w-4" />
</Button>
</CardFooter>
</Card>
);
}
Where to Go From Here
Everything we’ve built in this blog - the animated buttons, dialogs, cards, gesture interactions, and the Like button - is the kind of thing you’ll find yourself rebuilding across projects. At some point, it makes sense to stop rebuilding and start with a solid foundation.
That’s exactly why I built shadcn/studio.

Shadcn Studio goes beyond animations by providing animated blocks and sections built on top of Shadcn UI. It’s perfect for teams that want speed without compromising design quality.
Additionally, it comes with the following features:
-
Open-source: Dive into a growing, community-driven collection of copy-and-paste shadcn/ui components, shadcn blocks, and templates.
-
Component & Blocks variants: Access a diverse collection of customizable shadcn blocks and component variants to quickly build and style your UI with ease.
-
Animated variants with Motion: Add smooth, modern animations to your components, enhancing user experiences with minimal effort.
-
Landing pages & Dashboards: Explore 10+ premium & free Shadcn templates for dashboards, landing pages & more. Fully customizable & easy to use.
-
shadcn/ui for Figma: Speed up your workflow with Shadcn Figma UI components, blocks & templates — a full design library inspired by shadcn/ui.
-
Powerful theme generator: Customize your UI instantly with Shadcn Theme Generator. Preview changes in real time and create consistent, on-brand designs faster.
-
shadcn/studio MCP: Integrate shadcn/studio MCP Server directly into your favorite IDE and craft stunning shadcn/ui Components, Blocks, and Pages inspired by shadcn/studio.
-
Shadcn Figma To Code Plugin: Convert your Figma designs into production-ready code instantly with the Shadcn Figma Plugin.
-
Drag and Drop Builder: Build complete pages visually with drag and drop Shadcn Builder. Search and drag pre-built blocks (Hero, Features, Pricing, etc.) onto a canvas, rearrange them instantly, preview in real time, and export production-ready code or CLI install commands.
A lot of the animated patterns we covered here are already implemented there as production-ready components, used across dozens of blocks and templates you can drop straight into your project.
If you want to see every component from this guide running live in one place, I’ve also put together a StackBlitz demo that covers all of them - from the basics AnimatedCard all the way to the Like button. Good reference to keep open while you’re building.
Both are free and open-source. You can star and explore the full source on GitHub - worth a look if you want to move faster without starting from scratch every time.
Conclusion:
Animation is one of those skills that quietly separates good UIs from great ones. Users rarely notice when it’s done right - but they always notice when it’s missing or overdone.
Looking back at everything we’ve covered, the through-line has always been the same: use animation to communicate, not to decorate. Every transition, every spring, every stagger should make your UI clearer, more responsive, or more satisfying to use. If it doesn’t do at least one of those things - cut it.
The tools are now in your hands. shadcn/ui handles the structure, Motion handles the movement. Keep those concerns separate, start with purpose, and trust the physics.
Go build something that moves.