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-95for 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 easinganimate-*utilities applyanimationshorthand — name, duration, timing, iteration- v4 defines custom animations via
--animate-*in@theme, paired with@keyframes motion-safe:andmotion-reduce:respectprefers-reduced-motionmedia 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">
▶
</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-allincludes layout properties — Transitioningwidth,height, orpaddingtriggers layout recalculation, causing jank. Fix: Usetransition-transformandtransition-opacityfor smooth 60fps animations. -
Animation on mount — CSS animations run on initial render, which may cause a flash. Fix: Start with
opacity-0and useanimation-fill-mode: forwardsto end at full opacity. -
prefers-reduced-motionis important — Users with vestibular disorders can be harmed by motion. Fix: Wrap animations inmotion-safe:or providemotion-reduce:animate-none. -
animate-*replays on every re-render key change — Changing the Reactkeyremounts the element, replaying the animation. This can be intentional or a bug. -
GPU layers — Use
will-change-transformsparingly. Too many GPU layers waste memory. Fix: Only addwill-changeto elements that are actively animating.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Framer Motion | You need gesture-based, layout, and exit animations | You only need simple hover transitions |
CSS @starting-style | You need native CSS entry animations (no JS) | You need broad browser support today |
| GSAP | You need complex timeline-based animations | Simple CSS transitions suffice |
| React Spring | You want physics-based animation in React | You prefer CSS-native solutions |
| View Transitions API | You want page-to-page animated transitions | You 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 easinganimate-*— applies a@keyframesanimation 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 rotationanimate-ping— radar-style pulseanimate-pulse— gentle opacity pulseanimate-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>
);
}Related
- Utilities — core utility classes
- Custom Utilities — defining animation utilities
- Setup —
@themeconfiguration for animations - shadcn Dialog — animated dialog transitions