Tailwind Animation Utilities: Built-In Classes and Custom Keyframes
Tailwind's animation utilities go way beyond spin and pulse. Here's how to use built-in classes, write custom keyframes, and ship polished UI motion without a single line of vanilla CSS.
What Tailwind Actually Ships for Animation
Honestly, most developers use animate-spin on a loading icon, glance at the Tailwind docs once, and then never think about animation utilities again. That's leaving a lot on the table.
Out of the box with Tailwind v4.0.2, you get four named animations: animate-spin, animate-ping, animate-pulse, and animate-bounce. Each maps to a pre-defined @keyframes block that Tailwind injects into your stylesheet automatically — you never write the keyframe yourself.
animate-spin runs a full 360-degree rotation with linear easing over 1 second, looping infinitely. animate-ping scales an element from 1 to 2 and fades it out — the classic sonar ripple for notification badges. animate-pulse oscillates opacity between 100% and 50%, which is exactly what skeleton loaders need. animate-bounce does a vertical bounce with a custom easing curve.
These four cover a surprising number of real use cases. Skeleton screens, status indicators, loading spinners, and subtle attention-grabbers all live here. But when your design needs anything more specific, you're reaching for custom keyframes — and that's where Tailwind's config (or in v4, CSS-first config) does the heavy lifting.
The `animation` and `keyframes` Config Keys in Tailwind v4
In Tailwind v4, configuration moved from tailwind.config.js to your CSS file directly, using @theme. This is a genuine improvement — your design tokens and your animations live in one place, and you don't need a JS config file at all for most projects.
Here's a real example. Say you want a slide-in-from-bottom animation for modals and drawer panels. You define the keyframe and wire up a utility in a single @layer block:
@import "tailwindcss";
@theme {
--animate-slide-in: slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(24px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}After that, animate-slide-in becomes a valid Tailwind utility class. No plugin, no arbitrary value syntax — just className="animate-slide-in" on your component. Tailwind v4's JIT picks it up automatically.
Using Arbitrary Animation Values Without Config
Sometimes you need a one-off animation that doesn't warrant a named token. Tailwind's arbitrary value syntax handles this. You can write animate-[wiggle_0.5s_ease-in-out_infinite] directly in your JSX and it works — as long as the corresponding @keyframes wiggle exists somewhere in your stylesheet.
The catch: you still need to define the keyframe. Arbitrary values only cover the animation shorthand property. For the actual @keyframes declaration, you either put it in a global CSS file or use a @layer utilities block. This is a deliberate Tailwind design decision — it keeps the generated CSS clean and avoids duplicating keyframe blocks on every element.
For React projects, the cleanest pattern is a globals.css that imports Tailwind and declares all your custom keyframes, then uses @theme to expose named animation utilities. One file, everything in one place. If you're building reusable component libraries like Empire UI's component patterns, this approach scales well because consumers just import your CSS and get all the animations for free.
Arbitrary values are genuinely useful for prototyping. Nail the timing and easing in the browser, then promote the winner to a named token in @theme. That two-step workflow is faster than tweaking config files on every iteration.
Controlling Duration, Delay, and Iteration Count
Tailwind exposes animation-duration, animation-delay, and animation-iteration-count as separate utility axes — duration-*, delay-*, and repeat-related classes. In Tailwind v4.0.2, the duration-* classes apply to both transitions and animations depending on context, which trips up a lot of developers.
To be explicit about animation duration specifically, use the [animation-duration:Xms] arbitrary property or define it inside your @theme token. For delays, delay-150, delay-300, delay-500, and delay-700 are available by default. If you need delay-400, you're either adding it to @theme or using [animation-delay:400ms].
Staggered animations — where list items appear one after another — are the most common delay use case. A simple pattern: map over your array and apply style={{ animationDelay: ${index * 80}ms }} inline. That sidesteps Tailwind's purging entirely and gives you dynamic stagger without generating 20 delay utility classes.
function StaggeredList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item, i) => (
<li
key={item}
className="animate-slide-in opacity-0"
style={{ animationDelay: `${i * 80}ms`, animationFillMode: 'forwards' }}
>
{item}
</li>
))}
</ul>
);
}Respecting `prefers-reduced-motion` in Tailwind
Here's something you absolutely cannot skip: users with vestibular disorders, epilepsy, or motion sensitivity rely on prefers-reduced-motion to get a usable experience. Tailwind has a first-class modifier for this: motion-reduce: and motion-safe:.
The pattern is straightforward. You write the animated version first, then use motion-reduce: to tone it down or disable it entirely. motion-reduce:animate-none kills the animation for users who've opted out. motion-reduce:transition-none does the same for transitions. No media query boilerplate, no separate CSS file.
For components that use opacity fades or subtle transforms — think a theme toggle button that animates the icon swap — motion-reduce:duration-0 is enough. The visual change still happens, it's just instant. For spinning loaders and bouncing elements, motion-reduce:animate-none is the right call. Always test both states. The reduced-motion path often reveals that your UI actually works fine without the animation, which is a useful signal.
If you're building components that ship to other teams, bake the motion-reduce: variants in by default. Don't make accessibility an opt-in. Libraries like Empire UI's glassmorphism components follow this pattern — animated by default, sensible at reduced motion.
Custom Keyframes for Realistic UI Patterns
The four default animations are opinionated about easing. animate-bounce uses a specific spring-like curve. animate-pulse is linear. For UI that needs to feel physically grounded — overlays that open, cards that flip, tooltips that appear — you'll write your own keyframes with custom easing functions.
Worth knowing: cubic-bezier(0.16, 1, 0.3, 1) is a great general-purpose spring approximation. It starts fast and decelerates aggressively at the end, which reads as natural to the eye. You'll see it in most design systems including Radix, Arco Design, and Arc browser's interface. Compare it to the browser's built-in ease-out — the cubic-bezier version has a much more pronounced deceleration tail.
@theme {
/* Overlay fade + scale — for modals, dropdowns */
--animate-overlay-in: overlay-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
--animate-overlay-out: overlay-out 0.15s ease-in both;
/* Shimmer for skeleton loaders */
--animate-shimmer: shimmer 1.8s linear infinite;
@keyframes overlay-in {
from { opacity: 0; transform: scale(0.97); }
to { opacity: 1; transform: scale(1); }
}
@keyframes overlay-out {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.97); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
}The shimmer animation is particularly useful for skeleton screens. Pair it with a background: linear-gradient(90deg, rgba(255,255,255,0.0) 0%, rgba(255,255,255,0.15) 50%, rgba(255,255,255,0.0) 100%) and background-size: 200% 100% and you get the sweeping highlight effect without any JavaScript.
Animation Performance: What Actually Matters
Which CSS properties are safe to animate? transform and opacity — those two, always. They run on the compositor thread in every modern browser, meaning the main thread stays free for JavaScript. Animating anything else — width, height, top, left, background-color, border-radius in most cases — triggers layout or paint work, which can cause visible jank on lower-end devices.
The will-change property is a hint to the browser to promote an element to its own compositor layer before the animation starts. Tailwind exposes this as will-change-transform and will-change-auto. Apply will-change-transform only to elements that you know will animate imminently — putting it on everything defeats the purpose and increases GPU memory usage.
Does this mean you can never animate background-color? No. For slow, subtle transitions — like a glassmorphism card shifting its backdrop blur on hover — the browser handles it fine at 60fps on modern hardware. The rule is: use transform and opacity for anything that loops, runs at high frequency, or needs to animate many elements simultaneously. For one-off, user-triggered state changes, the flexibility is worth it.
Profile before you optimize. Chrome DevTools Performance panel will tell you exactly which frames are dropping and why. Gut feeling about animation performance is often wrong — what you think is janky is fine, and what you think is fine has a subtle layout thrash hiding in it.
Composing Animations with Tailwind's Group and Peer Modifiers
One underused pattern: triggering child animations when a parent receives focus or hover, using Tailwind's group and group-hover: modifiers. You add group to the parent and group-hover:animate-X to any descendant. The animation fires on child elements based on parent state — no JavaScript event handlers.
This pairs naturally with Tailwind v4's new features like the @starting-style support and native CSS anchor positioning. You can chain group-hover:animate-slide-in on a tooltip and group-hover:opacity-100 on an arrow, giving you a coordinated multi-element reveal with zero JS.
What about exit animations? That's still the hard part in CSS-only land. @starting-style handles enter animations natively in modern browsers (Chrome 117+, Firefox 129+). For exit, you still need JavaScript to delay removing the element from the DOM until the animation completes — or you use a library like Framer Motion for that layer. For most tooltip and dropdown patterns, the enter-only approach is acceptable. Exits can just be instant cuts — users rarely notice.
For particle effects and more complex scene-level animation, you're outside Tailwind's scope entirely. Background particle systems in React need canvas or WebGL — CSS keyframes can't handle thousands of independently moving elements efficiently. Know where the boundary is.
FAQ
Yes. Tailwind animation classes set the CSS animation property, while Framer Motion sets transform and opacity via inline styles. They don't conflict as long as you're not animating the same property with both. A common split: use Tailwind for looping ambient animations (shimmer, pulse) and Framer Motion for enter/exit transitions that need JS control.
In Tailwind v4, the token name must follow the pattern --animate-{name} exactly, and the value must be a valid CSS animation shorthand. If the keyframe name in your shorthand doesn't match an @keyframes block defined inside the same @theme block (or earlier in the file), the animation runs but does nothing. Double-check the keyframe name spelling — it's case-sensitive.
Use hover:[animation-play-state:paused] — Tailwind's arbitrary property syntax covers this. Apply it to the element carrying the animate-* class: className="animate-spin hover:[animation-play-state:paused]". This works in Tailwind v3.3+ and v4.
animate-pulse changes the element's opacity uniformly — the whole element fades in and out. A shimmer animation moves a gradient across the element's background, which reads more like a 'loading sweep' and works better for skeleton loaders that mimic text and image placeholders. Both have their place; the visual metaphor you pick depends on the context.
Each unique @keyframes block adds a fixed number of bytes to your CSS. A typical keyframe is 100-400 bytes. For reference, ten custom animations add roughly 2-4 KB uncompressed — negligible after gzip. The real concern is how many elements carry will-change-transform, not the keyframe declarations themselves.
No. The @keyframes block stays in the stylesheet — Tailwind doesn't do conditional keyframe removal. What motion-reduce:animate-none does is set animation: none on the element inside a @media (prefers-reduced-motion: reduce) block, which overrides the running animation. The keyframe declaration is harmless when no element references it.