Better understand Tailwind and animation with examples
This post is a hands-on tour of Tailwind CSS animations. You’ll learn:
- When to use built-in utilities
- How to compose animations with states and variants
- How to create custom keyframes (two approaches)
- How to use arbitrary values for fine-grained control
- Practical patterns: loaders, skeletons, staggered lists, and scroll-triggered effects
1) Quick wins with built-in animation utilities
Tailwind ships with helpful defaults:
animate-spinanimate-bounceanimate-pinganimate-pulse
Examples:
<!-- Spinner -->
<div class="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
<!-- Bouncy CTA -->
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500 animate-bounce">Get Started</button>
<!-- Notification dot -->
<span class="relative inline-flex h-3 w-3">
<span class="absolute inline-flex h-full w-full rounded-full bg-rose-500 opacity-75 animate-ping"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-rose-600"></span>
</span>
<!-- Subtle pulse for attention -->
<div class="px-3 py-2 rounded bg-amber-100 text-amber-800 animate-pulse">Syncing…</div>
Tips:
- Keep bouncy or flashy animations to small, key elements.
- Use them as temporary states (loading, notify) rather than permanent motion.
2) Compose animations with states (hover, focus, group)
You can start/stop animations with variants like hover:, focus:, and group-hover:.
<!-- Animate child only when card is hovered -->
<a href="#" class="group block rounded-lg border p-4 hover:shadow">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-sky-100 text-sky-700 grid place-items-center group-hover:animate-bounce">⚡</div>
<div>
<h3 class="font-semibold">Fast performance</h3>
<p class="text-sm text-slate-600">Optimized for speed</p>
</div>
</div>
</a>
Accessibility:
- Respect user preference with
motion-safe:andmotion-reduce:variants:
<!-- Only animate if user allows motion -->
<div class="motion-safe:animate-bounce motion-reduce:animate-none">...</div>
3) Custom animations (two approaches)
You’ll often want your own keyframes. You can define them in:
- Tailwind config (recommended for reusability)
- CSS via
@layer(fast in a single file)
A) Tailwind config (extend)
tailwind.config.js:
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'fade-in-up': {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
wiggle: 'wiggle 1s ease-in-out infinite',
'fade-in': 'fade-in .6s ease-out both',
'fade-in-up': 'fade-in-up .6s ease-out both',
},
},
},
plugins: [],
}
Usage:
<button class="px-3 py-2 rounded bg-emerald-600 text-white hover:bg-emerald-500 animate-wiggle">Save</button>
<div class="animate-fade-in">Hello</div>
<div class="animate-fade-in-up">I slide in</div>
B) CSS with @layer
Useful if you don’t want to touch the config or need a quick prototype.
/* globals.css (imported by your app) */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer keyframes {
@keyframes float {
0% { transform: translateY(0) }
50% { transform: translateY(-6px) }
100% { transform: translateY(0) }
}
}
@layer utilities {
.animate-float { animation: float 2s ease-in-out infinite }
}
<div class="inline-block animate-float">🌊</div>
4) Arbitrary values for micro-control
Tailwind’s JIT lets you dial in precise animation parameters without new config keys.
- Duration:
[animation-duration:1.5s] - Delay:
[animation-delay:250ms] - Timing:
[animation-timing-function:cubic-bezier(.2,.8,.2,1)] - Fill mode:
[animation-fill-mode:both] - Iterations:
[animation-iteration-count:3]orinfinite
If you defined a keyframe named fade-in, you can compose a one-off animation value:
<!-- keyframes fade-in must exist (via config or @layer) -->
<div class="animate-[fade-in_.8s_cubic-bezier(.2,.8,.2,1)_both]">Smooth</div>
5) Staggered list animation (no frameworks)
Use CSS variables for index-based delays.
@layer keyframes {
@keyframes fade-in-up { 0% { opacity: 0; transform: translateY(6px) } 100% { opacity: 1; transform: translateY(0) } }
}
@layer utilities {
.animate-fade-in-up { animation: fade-in-up .5s ease-out both }
}
<ul class="space-y-2">
<li style="--i:0" class="opacity-0 [animation-delay:calc(var(--i)*120ms)] animate-fade-in-up">Item A</li>
<li style="--i:1" class="opacity-0 [animation-delay:calc(var(--i)*120ms)] animate-fade-in-up">Item B</li>
<li style="--i:2" class="opacity-0 [animation-delay:calc(var(--i)*120ms)] animate-fade-in-up">Item C</li>
</ul>
You can also bake the delay into a reusable class if you generate it via a loop or a small utility function in your template.
6) Two practical patterns
A) Accessible loader
<div role="status" aria-live="polite" class="flex items-center gap-3">
<div class="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span class="text-slate-700">Loading…</span>
</div>
B) Skeleton with shimmer
@layer keyframes {
@keyframes shimmer {
0% { background-position: -200% 0 }
100% { background-position: 200% 0 }
}
}
<!-- Card skeleton -->
<div class="p-4 border rounded-md space-y-3">
<div class="h-4 w-1/3 rounded bg-[linear-gradient(110deg,#e5e7eb_8%,#f3f4f6_18%,#e5e7eb_33%)] bg-[length:200%_100%] animate-[shimmer_1.2s_linear_infinite]"></div>
<div class="h-3 w-3/4 rounded bg-[linear-gradient(110deg,#e5e7eb_8%,#f3f4f6_18%,#e5e7eb_33%)] bg-[length:200%_100%] animate-[shimmer_1.2s_linear_infinite]"></div>
<div class="h-3 w-2/4 rounded bg-[linear-gradient(110deg,#e5e7eb_8%,#f3f4f6_18%,#e5e7eb_33%)] bg-[length:200%_100%] animate-[shimmer_1.2s_linear_infinite]"></div>
</div>
7) Trigger on scroll with a tiny script
Use IntersectionObserver to add an animation class when elements enter the viewport.
<ul id="features" class="grid sm:grid-cols-2 gap-4">
<li class="opacity-0">Fast builds</li>
<li class="opacity-0">Tiny bundles</li>
<li class="opacity-0">A11y-first</li>
<li class="opacity-0">Great DX</li>
</ul>
<script>
const els = document.querySelectorAll('#features > li');
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add('animate-[fade-in-up_.5s_ease-out_both]');
io.unobserve(e.target);
}
}
}, { threshold: 0.15 });
els.forEach((el, i) => {
el.style.setProperty('--i', i);
el.style.animationDelay = `calc(var(--i) * 120ms)`;
io.observe(el);
});
</script>
Note: Ensure the fade-in-up keyframes exist (from earlier examples).
8) Performance and polish
- Prefer
transformandopacityfor smooth, GPU-friendly animations. - Avoid animating layout-affecting properties (like
top/left/width/height) when possible. - Keep durations short (150–600ms) to feel snappy.
- Use
motion-reduce:to respect accessibility preferences. - If using many simultaneous animations, consider
will-change: transformsparingly:[will-change:transform].
9) Cheatsheet
- Built-ins:
animate-spin,animate-bounce,animate-ping,animate-pulse. - Custom via config: extend
keyframesandanimation, thenanimate-<name>. - Custom via CSS:
@layer keyframes+@layer utilities. - Fine-tune with arbitrary values:
[animation-duration:...],[animation-delay:...],animate-[<keyframe>_<dur>_<timing>_<fill>_<count>]. - Combine with variants:
hover:,focus:,group-hover:,motion-safe:,motion-reduce:.
Wrap-up
Tailwind’s animation story is powerful because it’s composable: start with built-ins, add custom keyframes as needed, and finish with arbitrary values for precision. Use the snippets above as a toolbox for real-world loaders, skeletons, entrances, and subtle motion that respects users and performs well.