React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

tailwindanimationstransitionskeyframesmotion

Animations

Transitions, keyframes, and motion — smooth UI interactions with Tailwind CSS v4.

Recipe

Quick-reference recipe card — copy-paste ready.

// Transitions
<button className="transition-colors duration-200 ease-in-out hover:bg-blue-600">
<div className="transition-all duration-300 hover:scale-105 hover:shadow-lg">
<div className="transition-opacity duration-150 opacity-0 data-[visible]:opacity-100">
 
// Built-in animations
<div className="animate-spin">    {/* infinite rotation */}
<div className="animate-ping">    {/* pulse like a radar */}
<div className="animate-pulse">   {/* gentle opacity pulse */}
<div className="animate-bounce">  {/* bouncing */}
 
// Custom keyframe animation
<div className="animate-fade-in">  {/* defined in @theme */}
 
// Reduced motion
<div className="motion-safe:animate-bounce motion-reduce:animate-none">
/* globals.css — define custom animations */
@import "tailwindcss";
 
@theme {
  --animate-fade-in: fade-in 0.3s ease-out;
  --animate-slide-up: slide-up 0.4s ease-out;
  --animate-scale-in: scale-in 0.2s ease-out;
}
 
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
 
@keyframes slide-up {
  from { opacity: 0; transform: translateY(16px); }
  to { opacity: 1; transform: translateY(0); }
}
 
@keyframes scale-in {
  from { opacity: 0; transform: scale(0.95); }
  to { opacity: 1; transform: scale(1); }
}

When to reach for this: When UI interactions feel abrupt — add transitions for hover states, animations for entering elements, and loading indicators.

Working Example

"use client";
 
import { useState } from "react";
 
export function AnimatedNotificationList() {
  const [notifications, setNotifications] = useState<
    { id: number; text: string; type: "info" | "success" | "error" }[]
  >([]);
  let nextId = 0;
 
  function addNotification(type: "info" | "success" | "error") {
    const id = ++nextId;
    setNotifications((prev) => [
      ...prev,
      { id, text: `${type} notification #${id}`, type },
    ]);
    setTimeout(() => {
      setNotifications((prev) => prev.filter((n) => n.id !== id));
    }, 3000);
  }
 
  const colors = {
    info: "bg-blue-50 border-blue-200 text-blue-800",
    success: "bg-green-50 border-green-200 text-green-800",
    error: "bg-red-50 border-red-200 text-red-800",
  };
 
  return (
    <div className="space-y-4">
      <div className="flex gap-2">
        <button
          onClick={() => addNotification("info")}
          className="rounded bg-blue-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-blue-600 active:scale-95"
        >
          Info
        </button>
        <button
          onClick={() => addNotification("success")}
          className="rounded bg-green-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-green-600 active:scale-95"
        >
          Success
        </button>
        <button
          onClick={() => addNotification("error")}
          className="rounded bg-red-500 px-3 py-1.5 text-sm text-white transition-all duration-200 hover:bg-red-600 active:scale-95"
        >
          Error
        </button>
      </div>
 
      <div className="fixed top-4 right-4 z-50 flex w-80 flex-col gap-2">
        {notifications.map((n) => (
          <div
            key={n.id}
            className={`animate-slide-in-right rounded-lg border p-3 shadow-lg ${colors[n.type]}`}
          >
            <div className="flex items-center justify-between">
              <p className="text-sm font-medium">{n.text}</p>
              <button
                onClick={() => setNotifications((prev) => prev.filter((x) => x.id !== n.id))}
                className="ml-2 text-current opacity-50 transition-opacity hover:opacity-100"
              >
                X
              </button>
            </div>
            {/* Progress bar animation */}
            <div className="mt-2 h-0.5 animate-shrink rounded bg-current opacity-30" />
          </div>
        ))}
      </div>
    </div>
  );
}
/* Add these to globals.css */
@theme {
  --animate-slide-in-right: slide-in-right 0.3s ease-out;
  --animate-shrink: shrink 3s linear forwards;
}
 
@keyframes slide-in-right {
  from { opacity: 0; transform: translateX(100%); }
  to { opacity: 1; transform: translateX(0); }
}
 
@keyframes shrink {
  from { width: 100%; }
  to { width: 0%; }
}

What this demonstrates:

  • Custom slide-in animation for notifications
  • Progress bar with animation-fill-mode: forwards
  • active:scale-95 for button press feedback
  • Transition utilities for hover effects
  • Auto-dismiss with timer

Deep Dive

How It Works

  • transition-* utilities control which CSS properties transition, duration, and easing
  • animate-* utilities apply animation shorthand — name, duration, timing, iteration
  • v4 defines custom animations via --animate-* in @theme, paired with @keyframes
  • motion-safe: and motion-reduce: respect prefers-reduced-motion media query
  • Transitions activate on state changes (hover, focus, class toggle); animations run on mount or when triggered

Variations

Transition property groups:

// Specific properties (better performance)
<div className="transition-transform duration-300">         {/* only transform */}
<div className="transition-colors duration-200">            {/* only color props */}
<div className="transition-opacity duration-150">           {/* only opacity */}
<div className="transition-shadow duration-200">            {/* only box-shadow */}
<div className="transition-[transform,opacity] duration-300"> {/* custom set */}

Staggered enter animations:

{items.map((item, i) => (
  <div
    key={item.id}
    className="animate-fade-in opacity-0"
    style={{ animationDelay: `${i * 100}ms`, animationFillMode: "forwards" }}
  >
    {item.name}
  </div>
))}

CSS-only accordion:

<details className="group rounded border">
  <summary className="cursor-pointer p-4 font-medium">
    Click to expand
    <span className="ml-2 inline-block transition-transform group-open:rotate-90">
      &#9654;
    </span>
  </summary>
  <div className="overflow-hidden transition-all duration-300 group-open:max-h-96 max-h-0">
    <div className="p-4 pt-0">Hidden content here</div>
  </div>
</details>

Loading skeleton:

function Skeleton({ className }: { className?: string }) {
  return (
    <div className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`} />
  );
}
 
// Usage
<div className="space-y-3">
  <Skeleton className="h-6 w-3/4" />
  <Skeleton className="h-4 w-full" />
  <Skeleton className="h-4 w-5/6" />
</div>

TypeScript Notes

// Animation delay as a prop
function AnimatedItem({
  delay,
  children,
}: {
  delay: number;
  children: React.ReactNode;
}) {
  return (
    <div
      className="animate-fade-in opacity-0 [animation-fill-mode:forwards]"
      style={{ animationDelay: `${delay}ms` }}
    >
      {children}
    </div>
  );
}
 
// Transition event typing
function handleTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
  if (e.propertyName === "opacity") {
    // Opacity transition completed
  }
}

Gotchas

  • transition-all includes layout properties — Transitioning width, height, or padding triggers layout recalculation, causing jank. Fix: Use transition-transform and transition-opacity for smooth 60fps animations.

  • Animation on mount — CSS animations run on initial render, which may cause a flash. Fix: Start with opacity-0 and use animation-fill-mode: forwards to end at full opacity.

  • prefers-reduced-motion is important — Users with vestibular disorders can be harmed by motion. Fix: Wrap animations in motion-safe: or provide motion-reduce:animate-none.

  • animate-* replays on every re-render key change — Changing the React key remounts the element, replaying the animation. This can be intentional or a bug.

  • GPU layers — Use will-change-transform sparingly. Too many GPU layers waste memory. Fix: Only add will-change to elements that are actively animating.

Alternatives

AlternativeUse WhenDon't Use When
Framer MotionYou need gesture-based, layout, and exit animationsYou only need simple hover transitions
CSS @starting-styleYou need native CSS entry animations (no JS)You need broad browser support today
GSAPYou need complex timeline-based animationsSimple CSS transitions suffice
React SpringYou want physics-based animation in ReactYou prefer CSS-native solutions
View Transitions APIYou want page-to-page animated transitionsYou need element-level animations

FAQs

What is the difference between transition-* and animate-* utilities?
  • transition-* — activates on state changes (hover, focus, class toggle); controls which properties transition, duration, and easing
  • animate-* — applies a @keyframes animation that runs on mount or when triggered
How do you define a custom animation in Tailwind v4?
@theme {
  --animate-fade-in: fade-in 0.3s ease-out;
}
 
@keyframes fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

Then use className="animate-fade-in".

Why should you prefer transition-transform over transition-all?

transition-all includes layout properties like width and height, which trigger layout recalculation and cause jank. transition-transform and transition-opacity run on the GPU at 60fps.

How do you create staggered enter animations for a list of items?
{items.map((item, i) => (
  <div
    key={item.id}
    className="animate-fade-in opacity-0"
    style={{ animationDelay: `${i * 100}ms`, animationFillMode: "forwards" }}
  >
    {item.name}
  </div>
))}
How do you respect the user's reduced motion preference?

Wrap animations in motion-safe: or provide a fallback with motion-reduce:animate-none. These map to the prefers-reduced-motion media query.

What are the four built-in animation utilities?
  • animate-spin — infinite rotation
  • animate-ping — radar-style pulse
  • animate-pulse — gentle opacity pulse
  • animate-bounce — bouncing effect
How do you build a loading skeleton component?
function Skeleton({ className }: { className?: string }) {
  return (
    <div className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`} />
  );
}
Gotcha: An element starts visible, flashes, then animates in. What went wrong?

CSS animations run from the element's current state. If the element starts with opacity: 1, the first frame is visible. Fix by starting with opacity-0 and using animation-fill-mode: forwards (or [animation-fill-mode:forwards]).

Gotcha: Using will-change-transform on many elements. Is this safe?

No. Each will-change element creates a GPU layer. Too many layers waste memory. Only add will-change to elements that are actively animating, and remove it afterward.

How do you type a React TransitionEvent handler in TypeScript?
function handleTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
  if (e.propertyName === "opacity") {
    // opacity transition completed
  }
}
How do you type an AnimatedItem component that accepts a delay prop?
function AnimatedItem({
  delay,
  children,
}: {
  delay: number;
  children: React.ReactNode;
}) {
  return (
    <div
      className="animate-fade-in opacity-0 [animation-fill-mode:forwards]"
      style={{ animationDelay: `${delay}ms` }}
    >
      {children}
    </div>
  );
}