Skip to content
Chapter 21Lesson 5

Motion: transitions, keyframes, and tw-animate-css

Add motion to a Tailwind interface with CSS transitions, keyframe animations, and the tw-animate-css library, no JavaScript animation code.

The previous lesson left you with instant state flips: a button jumps from bg-primary to bg-accent the moment your pointer crosses it, a form turns red the instant a field reports invalid. For a hover that abruptness is fine. For a modal it reads as a glitch, because the eye registers a snap with no in-between frames as something breaking rather than arriving. So you reach for motion.

The common instinct is to install an animation library, usually Framer Motion. That is more machinery than the job needs: another dependency, and animation running in JavaScript on the same main thread that keeps your app responsive, all to fade in one dialog the platform can already animate for you.

You don’t need to leave CSS. A shadcn dialog animates in with exactly this on its content element:

"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"

No library, no JavaScript animation loop. Those are plain CSS utilities that read a data-state attribute the component sets as it opens.

We build up to it in three tiers, simplest first. Transitions are the cheapest motion: a property you already flip (on hover, on press, on a data-state) tweens from its old value to the new one instead of jumping. Keyframe animations run on their own, with no state change to trigger them, like spinners, skeletons, and pulses. Choreographed entrance and exit is the payoff: the dialog pattern above, on a component you ship every week.

Three mental models thread through all three tiers:

  1. Some properties are cheap to animate, most are expensive. This decides which animations are worth running at all, so we cover it first.
  2. State lives in React, motion lives in CSS. The component pushes its open or closed state onto a data- attribute; CSS reads that attribute and runs the motion. You write zero animation JavaScript. It’s the same move as the previous lesson, where the DOM held the state and React was only the mirror.
  3. You read motion utilities, you don’t reinvent them. A small library, tw-animate-css, ships the entrance, exit, and accordion keyframes shadcn relies on. You install it once and compose its utilities instead of hand-writing keyframes.

One idea runs underneath all of it. Some users have asked their operating system to reduce motion, and honoring that is not optional; treat it as a habit you build in, not something you bolt on at the end.

The render pipeline: why only transform and opacity are cheap

Section titled “The render pipeline: why only transform and opacity are cheap”

One rule decides which property you transition, and the lesson refers back to it throughout. When a property on an element changes, the browser updates the screen, and the amount of work depends on which property changed. There are three escalating stages.

The first is layout . Changing a property that affects geometry, such as width, height, top, left, margin, or padding, forces the browser to recompute where that element sits and usually where its neighbors sit too, because boxes push on each other. This is the most expensive stage.

The second is paint. Changing something purely visual that moves nothing, such as background-color, color, or box-shadow, skips the geometry math but still redraws the affected pixels.

The third and cheapest is compositing . Changing transform or opacity recomputes no geometry and redraws no pixels: the element is already painted onto its own layer, so the GPU just moves, scales, or fades that finished layer into place.

The stages run in order, layout forcing paint and paint forcing composite, and an animation runs the relevant chain once per frame, roughly sixty times a second. So the per-frame cost of the property you picked is the difference between motion that glides and motion that stutters.

The rendering pipeline
Layout recompute geometry
Paint redraw pixels
Composite GPU moves layer
width / height / top / margin
Layout
Paint
Composite
expensive
background-color / color / box-shadow
Paint
Composite
medium
transform / opacity
Composite
cheap — 60fps
The further down a property change reaches, the more work each frame costs.

This gives you the single rule that governs every animation you write:

Animate transform and opacity, and treat everything else as suspect. transform covers movement, scaling, and rotation; opacity covers fading. Between them they handle the overwhelming majority of UI motion, and both are composite-only on every modern browser, so both hit a smooth sixty frames per second with no per-frame layout or paint.

The other properties work too: animate height or top and the browser will do it. The cost shows up where it hurts most, on a low-end phone or in a list of a few hundred rows animating their height at once, where per-frame layout piles up and the motion drops frames. The fix is almost always a substitution the design won’t notice: translate instead of nudging top, scale instead of growing width, same visual result at a fraction of the cost. This is why the dialog you build at the end fades and scales rather than expanding its box, and why your card-hover effects lean on scale rather than margin.

This is the first and cheapest tier of motion, and you already have everything that triggers it: every hover:, active:, and data-[state=...]: from the previous lesson. A transition doesn’t decide when a property flips. It only fills in the frames between the old value and the new one.

A transition watches a named set of properties, and whenever one of them changes, it interpolates from the old value to the new over a duration instead of jumping. Something else still has to cause the change: a hover, a press, a data-state flip. Remove the trigger and nothing moves, because the transition is purely the in-between. Those interpolated frames are the tween .

In Tailwind a transition is assembled from four small utility families.

Which properties to watch. The base transition utility doesn’t watch every property, despite the name. In Tailwind v4 it watches a curated set worth animating: color, background-color, border-color, text-decoration-color, opacity, box-shadow, transform, filter, and backdrop-filter, leaving out the expensive geometry properties. To watch literally everything there’s transition-all, but you almost never want it: it pays to animate any property that changes, including ones you didn’t intend. Instead, name the property you’re animating: transition-colors on a button whose background shifts, transition-transform on a card that lifts, transition-opacity on something fading. There’s also transition-shadow for elevation and transition-none to switch motion off. Reach for transition-all only in tiny components where every animating property is one you intend.

How long it takes. duration-* sets the time in milliseconds, like duration-150 or duration-300. As with the elevation scale from the borders-and-shadows lesson, you reach from a small set of standard values rather than an arbitrary number.

  • duration-150 for snappy state changes such as hover, press, and focus. Fast enough to feel instant, still smooth.
  • duration-200 to duration-300 for entrances and exits, such as a dialog opening or a dropdown appearing. Long enough to read as arriving, short enough not to make the user wait.
  • 400ms and up is reserved for long, deliberate choreography. Almost nothing in a working interface belongs here.

The risk at the top of the range is sluggishness. A 600ms hover effect reads as a laggy interface, not an elegant one, because the user has moved on while the motion is still catching up. When in doubt, go shorter.

The shape of the curve. ease-* sets the easing : whether the motion starts slow and speeds up, starts fast and settles, or runs at a constant rate. The four you’ll use are ease-linear, ease-in, ease-out, and ease-in-out. Use ease-out for things appearing, since it moves fast then settles, like an object arriving and coming to rest. Use ease-in for things leaving, where motion is slow then accelerates away. Use ease-linear for anything that spins or shows steady progress. For ordinary UI, reach for ease-out by default.

Stagger, occasionally. delay-* holds off the start of a transition. You’ll mostly meet it when revealing a list one item at a time, each with a slightly larger delay so they cascade in.

Put it together on the button you’ve carried since the previous lesson. Its className there was hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 active:scale-[0.98]: a background flip on hover, a focus ring, and a press that shrinks the button to 98%. As written, every one of those snaps. The motion comes from one addition:

<button className="bg-primary transition-colors duration-150 hover:bg-accent active:scale-[0.98] focus-visible:ring-2 focus-visible:ring-ring/50">
Save changes
</button>

transition-colors duration-150 is all it takes for the hover background to glide in over 150ms instead of jumping. The press stays snappy and immediate, which is right: a button press wants instant feedback, not a slow squish. This one short addition is the single most common transition you’ll write.

Numbers on a page don’t teach the feel of a duration; you have to move it. The motion lab below lets you. Slide the duration to find where motion turns sluggish (past 400ms it starts to drag) and where it feels crisp (around 150ms), swap the easing curve to feel how ease-out settles differently from ease-in, and pick which property animates. The chip underneath echoes the resolved transition shorthand so you can connect the feel back to the values.

The motion lab — flip the toggle and watch the box move under your chosen settings.

Two states a static page cannot show you are :hover and :active: a screenshot can’t be hovered or pressed. To feel the press-and-lift pattern you have to build it live. In the exercise below, the target on the left is a card that lifts when you hover and dips when you press, while your version on the right starts flat. Add three utilities until your card matches: transition-transform, hover:scale-105 for the lift, and active:scale-95 for the press.

The target card lifts on hover and dips when pressed. Add a transform transition, a hover scale-up, and an active scale-down so your card matches. Hover and press both panels to compare.

Target
Your output LIVE

In your project, this card would use the semantic tokens from earlier in this chapter, such as bg-card and the elevation tokens, rather than the literal bg-white and shadow-md here. The exercise drops to literals only because its in-browser Tailwind doesn’t know your project’s custom tokens. The motion utilities are identical to what you’d ship.

Everything so far needed a trigger: a transition does nothing until a property changes, when you hover, press, or flip a data-state. Plenty of motion has no trigger. A loading spinner spins from the moment it mounts, a skeleton placeholder pulses while data loads, a notification dot ripples to draw the eye. None of these reacts to a state change; they just run.

A transition interpolates between an old and a new value when something changes. An animation plays a timeline, a sequence of keyframes , on its own, looping or running once, independent of any state change. If nothing is flipping a value and the motion still needs to run, reach for an animation.

Tailwind ships four animations, each mapped to a specific surface. Learn what each is for rather than memorizing a menu:

  • animate-spin is the loader: a continuous rotation for a spinning icon inside a pending button or a loading indicator.
  • animate-pulse is for skeleton placeholders: a soft opacity pulse signaling “content is loading here.” When a list or card is loading, prefer a pulsing skeleton over a spinner, because the skeleton previews the shape of what’s arriving.
  • animate-ping is the ripple: an expanding, fading ring, the notification-dot effect that pulls attention to something new.
  • animate-bounce is the attention nudge: a gentle vertical bounce, used sparingly on something like a “scroll down” arrow.

When the built-ins don’t cover what you need, you author your own keyframes. Say you want an invalid input to shake once, the small left-right shudder that flags a wrong field. In Tailwind v4 you define animations in CSS, in your app/globals.css, with no JavaScript config, the same CSS-first approach you used for the theme. It takes two pieces, so the walkthrough separates them: the first declares the timeline, the second registers it as a theme token, which is what mints the animate-* utility. The timeline only moves transform, so even custom keyframes stay in the cheap lane of the budget.

@import "tailwindcss";
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
@theme {
--animate-shake: shake 0.3s ease-in-out;
}

The timeline. @keyframes shake declares where transform sits at each point: centered at the ends, nudged left at 25%, right at 75%. The browser fills in the frames between. On its own this is a definition waiting to be used.

@import "tailwindcss";
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
@theme {
--animate-shake: shake 0.3s ease-in-out;
}

The registration. Inside @theme, a --animate-<name> token ties the keyframes to a duration and an easing, plus a run count if you want it to loop. Registering the token is what generates the utility.

@import "tailwindcss";
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
@theme {
--animate-shake: shake 0.3s ease-in-out;
}

The payoff: because the token is named --animate-shake, Tailwind exposes an animate-shake utility. Drop it onto an invalid field and it shudders once, composed like any other utility.

1 / 1

That @keyframes-plus-@theme pair is how the next section’s dialog entrance and exit animations are defined, except you won’t write those yourself.

Animating entrance and exit: tw-animate-css and data-state

Section titled “Animating entrance and exit: tw-animate-css and data-state”

The three mental models converge on a component you’ll ship constantly: a dialog that animates in when it opens and animates out before it disappears.

An entrance is easy: the dialog appears, and you transition it from faded-and-small to solid-and-full-size. The exit is the problem. To animate a dialog leaving, the element has to stay in the DOM long enough for the animation to play, but React’s instinct is to unmount it the instant isOpen flips to false, pulling it out of the DOM before a single frame can run. You can solve that by hand: track the open state, hold the unmount, toggle classes, wait for the animation to finish, then unmount. That’s a tangle of timing logic in JavaScript, exactly the React-state-mirroring-the-DOM machinery the previous lesson taught you to stop writing.

The 2026 solution makes the same move that lesson did: push the state down onto a data- attribute and let CSS do the work. Two pieces make it happen.

The keyframes for fading, zooming, and sliding aren’t ones you should write yourself. A CSS-first Tailwind v4 package, tw-animate-css , ships them, and new shadcn projects already depend on it. (If you’ve seen tailwindcss-animate in older code, this is its maintained successor; reach for tw-animate-css in new work.) Installing it is one line in app/globals.css, right after the Tailwind import:

@import "tailwindcss";
@import "tw-animate-css";

That import gives you a family of composable utilities:

  • animate-in and animate-out are the enter and exit primitives. Everything else modifies these.
  • Modifiers that stack onto them: fade-in-0 / fade-out-0 (opacity), zoom-in-95 / zoom-out-95 (scale from or to 95%), and the slide family slide-in-from-top-2, slide-in-from-bottom, slide-in-from-left, and so on.
  • The duration and easing utilities you already know (duration-200, ease-out), shared with core Tailwind.
  • The ready-made keyframes shadcn’s own components depend on, including the accordion and caret-blink animations we’ll come back to.

This is a surface you read, not one you reimplement: install it once, compose its utilities, and never hand-roll a fade or a zoom. Notice what those utilities animate. fade is opacity and zoom is scale, both composite-only and both inside the cheap lane of the budget, so the library’s defaults give you smooth motion for free.

The dialog primitive (shadcn builds these on top of Radix ) sets a data-state attribute on the content element: data-state="open" while it’s open, data-state="closed" while it’s closing. Your CSS targets each state and runs the matching direction:

"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"

Read it as two halves. When the state is open, run animate-in with a fade and a zoom, the entrance. When it’s closed, run animate-out with the reverse, the exit. The state lives in React, the motion lives in CSS, and the data-state attribute is the seam between them. Radix flips that one attribute; CSS does everything visual that follows. You write no animation JavaScript at all.

Radix also handles the exit-timing problem. When the dialog closes, it sets data-state="closed" and keeps the element mounted until the animate-out animation finishes, then removes it. The exit gets the frames it needs because Radix delays the unmount for you.

The sequence below scrubs a dialog through its full lifecycle. The left of each step shows the DOM, meaning the content element and its current data-state; the right shows the rendered frame at that moment. Step through it and watch the attribute on the left drive the animation on the right.

DOM
<button>Delete account</button>
no dialog content element in the DOM
Rendered
Closed. The trigger is visible; the dialog content is not in the DOM. data-state doesn't exist yet because the element doesn't.
DOM
<div data-state="open"
class="animate-in fade-in-0 zoom-in-95">
Rendered
Delete account? This action can't be undone. Delete
Opening. Radix mounts the content and sets data-state="open". The animate-in fade-in-0 zoom-in-95 utilities fire — the dialog is mid-entrance.
DOM
<div data-state="open"
class="animate-in fade-in-0 zoom-in-95">
Rendered
Delete account? This action can't be undone. Delete
Open. The entrance animation has settled. The dialog sits at full scale and full opacity; data-state stays "open".
DOM
<div data-state="closed"
class="animate-out fade-out-0 zoom-out-95">
Rendered
Delete account? This action can't be undone. Delete
Closing. data-state flips to "closed" and animate-out fade-out-0 zoom-out-95 plays. Radix keeps the element mounted until the exit animation ends, then removes it.

This is the literal className you’ll read in a shadcn DialogContent, walked through piece by piece. It’s a dense string, and the walkthrough separates the open half from the closed half so the two-direction pattern lands.

<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2
rounded-lg border bg-background p-6 shadow-lg duration-200
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
>
{children}
</DialogPrimitive.Content>

The base layout. Centered with fixed plus left-1/2 top-1/2 and the two -translate pulls, raised above the page with z-50, and given its surface: rounded corners, a border, bg-background, padding, and a shadow. None of this is motion; it’s the dialog at rest.

<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2
rounded-lg border bg-background p-6 shadow-lg duration-200
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
>
{children}
</DialogPrimitive.Content>

The entrance, which runs only while data-state is open: animate-in plus a fade from transparent (fade-in-0) and a zoom from 95% (zoom-in-95). Both fade and zoom are composite-cheap by design.

<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2
rounded-lg border bg-background p-6 shadow-lg duration-200
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
>
{children}
</DialogPrimitive.Content>

The exit, which runs only while data-state is closed: animate-out plus the reverse fade and zoom. This is why Radix keeps the element mounted until it finishes playing.

<DialogPrimitive.Content
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2
rounded-lg border bg-background p-6 shadow-lg duration-200
data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95
data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
>
{children}
</DialogPrimitive.Content>

One duration governs both directions: 200ms, straight from the entrance band, long enough to read as arriving and short enough not to make the user wait.

1 / 1

One common component makes the cheap-property rule and the choreography pattern collide. An accordion grows in height as it opens, and height is exactly the expensive, layout-triggering property the budget told you to avoid. Worse, the natural target for an opening panel is height: auto, and auto is a keyword the browser can’t animate toward, because there’s no number to interpolate to. The obvious approach is blocked on two counts.

shadcn works around it with a small trick you can copy. Radix measures the panel’s real pixel height and writes it into a CSS custom property, --radix-accordion-content-height, and the accordion-down and accordion-up keyframes (which tw-animate-css ships) animate height from 0 to that measured variable. Radix supplies the number the browser was missing, and the keyframe animates to it.

You’ve already watched transforms do real work: the scale in a zoom, the translate in a slide, the -translate-x-1/2 centering the dialog. There are four transform utilities, and every one is composite-only, in the cheap lane of the budget. That’s why they’re the animation primitives.

  • translate-x-* / translate-y-* move an element along an axis without disturbing its neighbors. Reach for this instead of nudging top or left.
  • scale-* grows or shrinks: scale-105 is a 5% grow, scale-95 a 5% shrink.
  • rotate-* spins around the center, as in rotate-12 or -rotate-3.
  • skew-* slants. It’s rare in product UI, named here so you recognize it.

A few of these become second nature. hover:scale-105 is the card-lift, the subtle grow that signals “this is interactive.” active:scale-95 (or active:scale-[0.98] on your button) is the press-feedback, the small dip that makes a click feel physical. -translate-y-1 on hover is a gentle raise, an alternative to scaling. And rotate paired with a data-state, like data-[state=open]:rotate-180 on a chevron, flips dropdown and accordion indicators to point the other way when they open, tying the motion back to the same data-state seam.

You’ll occasionally see transform-gpu, which hints to the browser to promote the element to its own compositor layer. Worth recognizing, but you’ll rarely reach for it by hand, since the browser already promotes animating transforms on its own.

prefers-reduced-motion: motion you can turn off

Section titled “prefers-reduced-motion: motion you can turn off”

Every animation visible enough to notice needs a reduced-motion escape. This project’s code conventions allow no exceptions, and the reason is concrete.

Some people get genuinely unwell from on-screen motion. The clearest case is a vestibular disorder, where movement the body didn’t make disturbs the inner-ear balance system, but motion sensitivity and attention needs matter too. For these users a big sliding panel or a parallax scroll can be nauseating or disorienting. Their operating system offers a “Reduce motion” setting, and the browser exposes whether it’s on through the prefers-reduced-motion media query. Tailwind gives you that as the motion-reduce: variant.

Pair each animation with a motion-reduce: escape that switches it off:

<div className="animate-in slide-in-from-bottom-4 motion-reduce:animate-none">

motion-reduce:transition-none and motion-reduce:animate-none are the two you’ll write most: for users who asked for less motion, they skip the tween or the keyframes and snap straight to the final state.

The nuance that’s easy to get wrong: reduced motion does not mean no motion. You cut the decorative motion, such as the parallax, the big slides, and the attention-grabbing bounce, and keep the functional motion that communicates something. A loading spinner keeps spinning under reduced motion, because the spin is the information: it tells the user the app is working. A focus ring and a progress indicator stay too. What you strip is motion that’s there for flourish rather than meaning. So it’s a judgment call, element by element, not a blanket * { animation: none } that removes the spinner along with the parallax.

There’s a mirror-image variant, motion-safe:, which applies only when motion is allowed. Instead of adding motion and subtracting it for sensitive users, you withhold it by default and add it only when the user hasn’t asked to reduce it. Either direction works, so pick whichever reads more clearly for the animation at hand.

You don’t have to wire this up everywhere yourself. shadcn and Radix components ship sensible reduced-motion behavior, so the dialog you built earlier honors the setting untouched. The motion-reduce: discipline is your job on the custom motion you author: the shake keyframe from a moment ago, or a hover-lift you styled by hand. (The accessibility-baseline chapter gives this habit its proper home later; here you’re just installing it.)

Two quick checks. First, sort these properties by what they cost to animate, the foundational rule of the whole lesson. Drag each into the lane it belongs to.

Sort each property by what it costs the browser to animate. Drag each item into the bucket it belongs to, then press Check.

Cheap Composite-only — the GPU just moves a finished layer
Expensive Forces layout or paint every frame
transform
opacity
scale
width
height
top
margin
background-color

Second, the choreography seam. A dialog needs to animate out before it leaves the screen — which approach is the 2026 one?

A closeDialog() call flips isOpen to false, and the dialog’s content carries an animate-out fade-out-0 zoom-out-95 exit. On screen, the dialog vanishes instantly — not one frame of the exit plays. What’s the 2026 fix?

Stop unmounting on the boolean. Let the dialog primitive own the close: it flips the content’s data-state to closed, holds the element in the DOM while the exit plays, and unmounts only after it finishes.
Wrap the unmount in a setTimeout whose delay equals the animation’s duration-200, so the element survives long enough for the exit to finish.
Move the exit onto the overlay instead of the content — the backdrop stays mounted longer, so its animate-out always has frames to play.
Add motion-safe:animate-out so the browser knows to defer the unmount until motion is allowed to complete.

And the reduced-motion nuance, the part most people get wrong:

A user has turned on “Reduce motion” at the OS level. Three animations are on the screen. Which one is the one you should leave running?

A button you just clicked shows a spinning icon while its request is in flight.
An arrow on an empty state hops up and down to draw the eye toward it.
A settings panel glides the full width of the screen as it opens from the right.

Almost everything you’ll animate on a web app lives in what you just learned: CSS transitions, keyframes, and tw-animate-css. Two tools sit deliberately outside that lane, worth naming so you recognize them.

The first is the View Transitions API . CSS animates a single element changing; View Transitions animate the whole page changing between two states, including across a route change. Reach for it when the animation spans a navigation or a large DOM swap that per-element CSS can’t choreograph. Support is uneven: Chromium and Safari 18 handle same-document transitions, Firefox is behind a flag as of early 2026. Next.js 16 exposes it behind experimental.viewTransition: true, paired with React 19.2’s <ViewTransition>. A later part of the course covers it.

The second is Framer Motion (now just Motion), the JavaScript library from this lesson’s intro. It’s powerful for spring physics and gesture-driven motion, and overkill for the surface this course ships, where CSS plus tw-animate-css covers what you’ll hit.