Animations
Transitions, keyframes, and motion - smooth UI interactions with Tailwind CSS v4.
Search across all documentation pages
Transitions, keyframes, and motion - smooth UI interactions with Tailwind CSS v4.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
animation-fill-mode: forwardsactive:scale-95 for button press feedbacktransition-* utilities control which CSS properties transition, duration, and easinganimate-* utilities apply animation shorthand - name, duration, timing, iteration--animate-* in @theme, paired with @keyframesmotion-safe: and motion-reduce: respect prefers-reduced-motion media queryTransition 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>// 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
}
}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.
| 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 |
transition-* - activates on state changes (hover, focus, class toggle); controls which properties transition, duration, and easinganimate-* - applies a @keyframes animation that runs on mount or when triggered@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".
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.
{items.map((item, i) => (
<div
key={item.id}
className="animate-fade-in opacity-0"
style={{ animationDelay: `${i * 100}ms`, animationFillMode: "forwards" }}
>
{item.name}
</div>
))}Wrap animations in motion-safe: or provide a fallback with motion-reduce:animate-none. These map to the prefers-reduced-motion media query.
animate-spin - infinite rotationanimate-ping - radar-style pulseanimate-pulse - gentle opacity pulseanimate-bounce - bouncing effectfunction Skeleton({ className }: { className?: string }) {
return (
<div className={`animate-pulse rounded bg-gray-200 dark:bg-gray-700 ${className}`} />
);
}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]).
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.
function handleTransitionEnd(e: React.TransitionEvent<HTMLDivElement>) {
if (e.propertyName === "opacity") {
// opacity transition completed
}
}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>
);
}@theme configuration for animationsReviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥