Skip to content

Shadcn Framer Motion: Creating Animated Components Easily in 5 Parts

Written By Ajay Patel
56 min read

Shadcn Framer Motion: Creating Animated Components Easily in 5 Parts

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

shadcn framer motion components

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, not framer-motion. If you’re on an older project using framer-motion, it still works - but new projects should use motion. 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 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 Link rather than using asChild

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" on AnimatePresence ensures 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 AnimatePresence to detect which child is entering and which is exiting.

  • overflow-hidden on the button clips any content that slides outside the button boundary during transition.

  • min-w-35 prevents 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} on AnimatePresence prevents 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:

  • useMotionValue creates a reactive value that Motion can track — think of it like useState but optimized for animations (it doesn’t trigger re-renders).

  • useSpring wraps 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.

  • useTransform maps one range of values to another. Here we’re saying: “when springY is 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" on AnimatePresence is different from mode="wait". Instead of waiting for one element to exit before the next enters, popLayout immediately 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.

  • layout on 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 shakeKeyframes object 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 AnimatePresence That works for all routes

  • The key trick 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. AnimatePresence is a client component, which is why we isolate it inside providers.tsx with "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.

  • useTransform then 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, and backdropFilter triggers 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 to transform and opacity which 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 dragElastic at 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 in onDragEnd).

useTransform For indicators,

  • the LIKE and NOPE labels are tied directly to the x MotionValue. No useState, 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’s select-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 staggerChildren values small - between 0.04 and 0.1 seconds. 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 motion elements as a style prop

  • 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 via textContent — no state, no re-renders, silky smooth. The tabular-nums class 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:

  1. The heart icon scales up with a spring bounce

  2. Particle bursts fly outward in multiple directions

  3. A ripple ring expands and fades

  4. The like count increments with a slide animation

  5. 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. The times array [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 animation library

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.