9 Tailwind v4 Animations — One Line, Copy-Paste
These nine Tailwind v4 animations turn one utility class into a real keyframe, a hover state, or a reduced-motion guard, using only the framework's
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 @theme custom keyframes
- 02 tw-animate-css entrance utility
- 03 Fade + slide utility
- 04 Arbitrary-value scroll-timeline
- 05 group-hover icon
- 06 data-state animation (shadcn)
- 07 motion-safe guard
- 08 Spin utility
- 09 Stagger utility
The nine below move from registering a brand-new animation to extending one Tailwind already ships. 01 through 03 cover the CSS-first way to declare a custom keyframe inside @theme, and 04 shows how to fake a feature browsers still disagree on. 05 and 06 react to hover and to a JavaScript-driven state, 07 is the accessibility guard on its own, and 08 and 09 stretch a built-in utility instead of writing a new one. Only the Tailwind fragment sits in the code block below each entry — the compiled CSS, the plain-SCSS twin, and a React version of all nine live together in one archive.
01@theme Custom Keyframes
Writing an --animate-pop-spin variable together with its @keyframes inside a CSS @theme block turns that name straight into an animate-pop-spin utility class — no tailwind.config.js required. It suits a badge that only needs to spin into view once, such as a new-item tag or an updated notification count.
<style>
@theme {
--animate-pop-spin: pop-spin 900ms cubic-bezier(.34,1.56,.64,1) both;
@keyframes pop-spin {
0% { opacity: 0; transform: scale(.5) rotate(-8deg); }
60% { opacity: 1; transform: scale(1.08) rotate(3deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
}
</style>
<span class="animate-pop-spin motion-reduce:animate-none rounded-full bg-[#fff7e6] px-4 py-2 font-bold">NEW</span>
02tw-animate-css Entrance Utility
Installing the tw-animate-css plugin and applying its animate-in fade-in slide-in-from-top-8 duration-500 combination makes a card start 32px higher and fully transparent, then settle into place while it fades in. It fits a toast or a modal's very first appearance, or a card sliding into a list.
npm install -D tw-animate-css
<!-- add one line to app.css: @import "tw-animate-css"; -->
<div
class="animate-in fade-in slide-in-from-top-8 duration-500
motion-reduce:animate-none
rounded-2xl bg-[#17141a] p-6"
>
<p class="text-[#fff7e6] font-bold">Deployed</p>
<p class="text-[#fff7e6]/70 text-sm">The new version just went live</p>
</div>
03Fade + Slide Utility
Defining just one --animate-fade-slide variable inside @theme — no plugin needed — bundles opacity and translateY into a single keyframe, so a paragraph rises 16px into view with one class. It works well for a hero line or a section title that should feel like it's settling into place.
<style>
@theme {
--animate-fade-slide: fade-slide 400ms ease-out both;
@keyframes fade-slide {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
}
</style>
<p class="animate-fade-slide text-center font-bold text-2xl">One line is all it takes</p>
04Arbitrary-Value scroll-timeline
Tailwind ships no dedicated scroll-timeline utility, so it gets attached through the arbitrary property [animation-timeline:scroll()] instead. Because browser support still varies, this demo reproduces the same fill progress with a safe looping animation rather than the real scroll-driven one.
<!-- only in supporting browsers — check caniuse.com before shipping this line -->
<div class="h-2 rounded-full bg-black/10">
<div
class="h-full rounded-full bg-[#2f6df6] origin-left
animate-[fill_1s_linear] [animation-timeline:scroll()] [animation-range:0%_100%]"
></div>
</div>
05group-hover Icon
Putting group on the parent and group-hover:translate-x-1 on the icon means hovering anywhere on the card slides just the inner arrow 4px to the right. It's a light nudge that suits a card-style call to action or an arrow sitting at the end of a list row.
<a class="group inline-flex items-center gap-3 rounded-xl bg-white/8 px-6 py-4" href="/more">
<span class="text-[#fff7e6] font-semibold">Learn more</span>
<svg class="size-4 text-[#2f6df6] transition-transform duration-200
group-hover:translate-x-1 group-focus-visible:translate-x-1" viewBox="0 0 24 24">
<path d="M4 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" />
</svg>
</a>
06data-state Animation (shadcn)
Like shadcn's own components, a small script only toggles data-state="open" on click, and CSS reacts through the data-[state=open]: variant to drive scale-y-100 and opacity-100. Nothing here touches width, height, or grid-template-rows, so the panel opens without forcing a layout recalculation.
<button onclick="p.dataset.state = p.dataset.state === 'open' ? 'closed' : 'open'"
aria-expanded="false" class="w-full rounded-xl bg-[#17141a] px-5 py-4 text-left text-[#fff7e6]">
Shipping details
</button>
<div id="p" data-state="closed"
class="origin-top scale-y-0 opacity-0 transition-all duration-[220ms] ease-out
data-[state=open]:scale-y-100 data-[state=open]:opacity-100">
<p>Orders ship within one to two business days after checkout.</p>
</div>
07motion-safe Guard
Pairing motion-safe:hover:-translate-y-1 with motion-reduce:hover:shadow-md needs no separate @media block at all. A reduced-motion visitor only sees the shadow soften, because the lift itself is never attached in the first place.
<button
class="rounded-xl bg-[#fff7e6] px-8 py-4 font-bold shadow-md transition-all duration-[220ms] ease-[cubic-bezier(0.2,0.8,0.2,1)]
motion-safe:hover:-translate-y-1 motion-safe:hover:shadow-lg
motion-reduce:hover:shadow-md"
>
Save changes
</button>
08Spin Utility
Leaving the built-in animate-spin (one second) untouched and adding just --animate-spin-slow: spin 3s linear infinite inside @theme lets a logo and a loading ring share the exact same rotation at two different speeds. Neither utility needs its own @keyframes block, since both point back to the same spin keyframe Tailwind already ships.
<style>
@theme {
--animate-spin-slow: spin 3s linear infinite;
}
</style>
<div class="flex items-center gap-8">
<span class="animate-spin size-14 rounded-full border-4 border-black/10 border-t-[#2f6df6]"></span>
<span class="animate-spin-slow size-14 rounded-full border-4 border-black/10 border-t-[#17141a]"></span>
</div>
09Stagger Utility
Giving each list item [animation-delay:calc(var(--i)*80ms)] and simply changing --i to 0, 1, 2 and so on makes one shared animate-fade-slide class rise in, each item landing 80ms after the last. It reads as a sequence instead of everything appearing on screen at once.
<ul class="flex flex-col gap-2">
<li style="--i:0" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
New comment on your post
</li>
<li style="--i:1" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
You have a new follower
</li>
<li style="--i:2" class="animate-fade-slide [animation-delay:calc(var(--i)*80ms)] rounded-lg bg-white/8 px-4 py-2">
Payment completed
</li>
</ul>
Where Do Tailwind v4 Animations Break?
The most common failure happens on the way from v3 to v4. Leaving theme.extend.keyframes and theme.extend.animation inside tailwind.config.js and just swapping a class name to animate-pop-spin does nothing at all, because v4 is CSS-first and never reads that file. The official theme documentation is explicit that the definition has to live inside a CSS @theme block instead. Item 06's accordion hides a related trap: animating grid-template-rows from 0fr to 1fr looks like the obvious way to open a panel, but it forces the browser to recompute the track size on every frame. That fights the transform-and-opacity approach the rest of this set relies on, so this article swaps in scaleY with transform-origin: top to sidestep the recalculation. The trade-off is that the panel's layout space still stays reserved either way. Item 08's second ring carries a smaller version of the same lesson: writing it a second @keyframes block would only grow the compiled CSS. Overriding just animation-duration on the shared spin keyframe is all a second speed needs. Every one of these nine, wired up with its SCSS twin and a React version, sits in the download and opens with mtsjkxrf typed exactly as it appears on this line, with no spaces added anywhere.
Accessibility
All nine handle prefers-reduced-motion, but not with the same technique. Items 01 through 04, 06, 08, and 09 pair their custom keyframe with motion-reduce:animate-none, so the animation itself turns off. Only the finished state stays on screen — a settled badge, a visible card, a filled track. Items 05 and 07 never attach the motion in the first place, since motion-safe: scopes the hover lift and the group-hover nudge to visitors who haven't asked for less movement. Item 06's panel keeps opacity-100 doing the work of showing "open" even with motion turned off, so the state itself is never lost, only the transition getting there. The same guard reads just as plainly written as a raw media query:
@media (prefers-reduced-motion: reduce) {
.badge { animation: none; }
}
MDN's prefers-reduced-motion reference lists the operating-system toggles this media feature reads from.
The rest of this site's CSS animation posts sit in the CSS category hub, and what's actually inside the download versus what's free to read here is covered on the about page.
FAQ
Do these Tailwind v4 animations work without the tw-animate-css plugin?
Eight of the nine need no plugin at all — @theme alone covers custom keyframes, state-based variants, and the built-in animate-spin extension. Only item 02's directional entrance classes come from tw-animate-css, and item 03 shows how to hand-roll that same kind of entrance with a single @theme variable instead.
Can I mix these Tailwind v4 animations with plain CSS or SCSS?
Yes — every class here compiles down to ordinary CSS, so nothing stops a hand-written @keyframes block from sitting next to a Tailwind utility in the same stylesheet. The archive ships an SCSS file per item that reproduces each animation without Tailwind at all, so the two approaches sit side by side for comparison.
Why does item 04 avoid the real animation-timeline: scroll() property?
Because browser support is still uneven — caniuse's scroll-timeline table shows Chrome and Edge shipping it at version 115, Safari following at 26 in September 2025, and Firefox still behind a flag as of September 2026. Shipping the arbitrary-property version today with no fallback would leave part of the audience looking at a track that never fills, so this demo swaps in a safe looping animation that reaches the same finished state.