Skip to content
Chapter 21Lesson 5

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

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

The lesson before this one left you with a stack of instant state flips. A button goes from bg-primary to bg-accent the moment your pointer crosses it, and a form turns red the instant a field inside it reports invalid. The browser swaps one painted frame for another with nothing in between. That abruptness is fine for some changes and wrong for others. When a modal snaps into existence with no in-between frames, most people read it as a glitch: the eye registers the jump as something breaking rather than something arriving.

So you reach for motion. When a lot of developers first want a dialog to fade and scale into view, their instinct is to install an animation library, usually Framer Motion. That instinct is worth pausing on, because it brings far more machinery than this job needs. A React animation library means another dependency in your bundle, a second mental model layered on top of the one you already have, and animation work running in JavaScript on the main thread, the same thread that is trying to keep your app responsive. For one dialog that fades in, that is a steep price to solve a problem the platform already solves.

The 2026 answer is that you don’t 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, no second mental model. Those are plain CSS utilities, and they read a data-state attribute that the component sets as it opens. That one line is where this whole lesson is heading, and by the time you reach it you’ll be able to read every piece.

We’ll get there in three tiers, simplest first. Transitions come first: the cheapest motion, where a property you already know how to flip (on hover, on press, on a data-state) tweens from its old value to the new one instead of jumping. Keyframe animations come next: motion that runs on its own, such as spinners, skeletons, and pulses, with nothing changing state to trigger it. Choreographed entrance and exit is the payoff: the dialog pattern above, where all the ideas combine on a component you’ll ship every week.

Three mental models thread through all three tiers, and they’re the part to keep:

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

One more idea runs underneath all of it, and it’s a matter of discipline. Some of your users have asked their operating system to reduce motion, and honoring that request is not optional. We’ll treat it as a habit you build in, not something you bolt on at the end.

What is cheap to animate: the compositor budget

Section titled “What is cheap to animate: the compositor budget”

Before any animation utility, start with one rule. It underpins everything else in the lesson: which property to transition, and why the dialog fades and scales instead of growing in height all come back to it. So we cover it first and refer back to it for the rest of the lesson.

When a property on an element changes, the browser has to update the screen, and depending on which property changed it does a different amount of work. There are three escalating stages.

The first is layout ((Also called reflow: the browser recomputes the geometry, meaning the position and size, of elements on the page.)) . When you change something that affects an element’s geometry, such as its width, height, top, left, margin, or padding, the browser has to recompute where that element sits and, often, where everything around it sits too, because boxes push on their neighbors. This is the most expensive stage.

The second is paint. When you change something purely visual that doesn’t move anything, such as background-color, color, or box-shadow, the browser skips the geometry math but still has to redraw the affected pixels.

The third and cheapest is compositing ((The GPU combining already-painted layers into the final on-screen frame, moving and blending them without redrawing their pixels.)) . When you change transform or opacity, the browser recomputes no geometry and redraws no pixels. The element was already painted onto its own layer, so the GPU just moves, scales, or fades that finished layer into place.

This matters so much for motion because of how the stages stack and repeat. They run in order, where layout forces paint and paint forces composite, and an animation runs the whole relevant chain not once but roughly sixty times a second, once per frame. So the per-frame cost of the property you picked is the difference between motion that glides and motion that stutters.

The following diagram lays the three stages out as a pipeline. Watch how far down the chain each kind of property change reaches: a geometry change runs the whole strip, a color change skips the first stage, and a transform or opacity change touches only the last, cheapest stage.

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 rendering pipeline. The further down a property change reaches, the more work each animation frame costs.

That gives you the single rule that governs every animation you’ll write:

Animate transform and opacity, and treat everything else as suspect. transform covers movement, scaling, and rotation, and 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.

Everything outside those two works, in the sense that you can animate height or top and the browser will do it. The problem shows up on exactly the surfaces where it hurts most. On a low-end phone, or in a list with a few hundred rows all animating their height at once, the per-frame layout cost piles up and the motion drops frames and stutters. The fix is almost always a substitution the design won’t even notice: translate instead of nudging top, scale instead of growing width. You get the same visual result at a fraction of the cost. This is why the dialog you’ll build at the end fades and scales rather than expanding its box, and why your card-hover effects will lean on scale rather than margin.

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

That’s the whole model, and it’s worth stating precisely, because the word gets used loosely. A transition ((Animation that interpolates a property from its old value to a new value when that value changes.)) watches a named set of properties, and whenever one of those properties changes value, it interpolates from the old value to the new value over a duration instead of jumping. Something still has to cause the change: a hover, a press, or a data-state flip. The transition is purely the in-between, so if you remove the trigger, nothing moves. The interpolated frames it generates are the tween ((The in-between frames a transition or animation generates between two values, short for “in-betweening”.)) .

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

Which properties to watch. The base transition utility doesn’t watch every property, and that detail is worth getting right because the name suggests otherwise. In Tailwind v4, transition watches a curated set of the properties that are normally worth animating: color, background-color, border-color, text-decoration-color, opacity, box-shadow, transform, filter, and backdrop-filter. It deliberately leaves out the expensive geometry properties. If you genuinely want to watch literally every property, that’s the separate transition-all utility, and you almost never want it, because it pays the cost of animating any property that happens to change, including ones you didn’t mean to. The better habit is to 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 changes and transition-none to switch motion off. Reach for transition-all only in tiny components where you’re certain every animating property is one you intend.

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

  • duration-150 for snappy state changes such as hover, press, and focus. Fast enough to feel instant, but 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-form, deliberate choreography. Almost nothing in a working interface belongs here.

The risk at the top of the range is sluggishness. A 600ms hover effect doesn’t read as elegant, it reads as a laggy interface, because the user is already moving on while the motion is still catching up. When in doubt, go shorter.

The shape of the curve. ease-* sets the easing ((The timing function mapping elapsed time to animation progress; it controls whether motion starts fast, ends fast, or runs evenly.)) , which is 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, because it moves fast and then settles, which feels like an object arriving and coming to rest. Use ease-in for things leaving, where the motion is slow and then accelerates away. Use ease-linear for anything that spins or shows steady progress, where a constant rate is correct. 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 of items one after another, each with a slightly larger delay, so they cascade in. It’s worth knowing it exists, but not worth dwelling on.

Put it together on the button you’ve been carrying since the previous lesson. Its full 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 at the end stays snappy and immediate, which is exactly right, because a button press wants instant feedback rather than a slow squish. One short addition turns a flip into motion, and it’s 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 following motion lab lets you do that. Slide the duration to find where motion turns sluggish (somewhere 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 genuinely cannot show you are :hover and :active, since a screenshot can’t be hovered or pressed. So the way to feel the press-and-lift pattern is to build it live. In the following exercise, the target on the left is a card that lifts when you hover it and dips when you press it, while your version on the right starts flat. Add the three utilities, a transition-transform, a hover:scale-105 for the lift, and an active:scale-95 for the press, until your card matches.

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 actual project, the card above 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 the exercise uses. The exercise drops to literals only because the in-browser Tailwind it runs on doesn’t know your project’s custom tokens. The motion utilities themselves are identical to what you’d ship.

Everything so far needed a trigger. A transition sits there doing nothing until a property changes, meaning until you hover, press, or flip a data-state. But plenty of motion has no trigger at all. A loading spinner spins from the moment it mounts. A skeleton placeholder pulses while data loads. A notification dot quietly ripples to draw the eye. None of those is reacting to a state change; they just run.

Drawing that line is the real work of this tier, because it’s easy to use the two words interchangeably. A transition interpolates between an old and a new value when something changes. An animation plays a timeline, a sequence of keyframes ((A named timeline of property values at points from 0% to 100% that an animation plays through.)) , 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, you need an animation, not a transition.

Tailwind ships four animations ready to use, and each maps to a specific surface. Learn what each one is for rather than memorizing a menu:

  • animate-spin is the loader: a continuous rotation, your everyday choice for a spinning icon inside a pending button or a loading indicator.
  • animate-pulse is for skeleton placeholders: a soft opacity pulse that signals “content is loading here.” When a list or card is loading, the better choice is 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 on an empty state.

When the four built-ins don’t cover what you need, you author your own keyframes, and this is the one genuinely new mechanism in this tier. Say you want an invalid input to shake once, the small left-right shudder that tells the user the field is wrong. None of the four built-ins do that. In Tailwind v4, animations are defined in CSS, in your app/globals.css, with no JavaScript config file involved, the same CSS-first approach you used to set up the theme. It takes two pieces that are easy to conflate, so the following walkthrough separates them. The first piece declares the timeline, and the second registers it as a theme token, which is what turns it into a usable animate-* utility. Notice that the timeline only moves transform, so even your 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 from 0% to 100%: centered at the ends, nudged left at 25%, and right at 75%. The browser fills in the frames between. This block on its own does nothing yet; it’s 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 it as a theme token is what generates the matching 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 now exposes an animate-shake utility. Drop animate-shake onto an invalid field and it shudders once, composed like any other utility.

1 / 1

That @keyframes-plus-@theme pair is the foundation the next section builds on. It’s exactly how the entrance and exit animations for dialogs are defined, except that you won’t write those yourself.

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

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

This is the tier the whole lesson has been building toward, and it’s where 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.

Start with why this is genuinely harder than a hover transition, because the difficulty is the whole reason the pattern exists. An entrance is easy enough: 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 the natural React instinct is to unmount it the instant isOpen flips to false, which pulls it out of the DOM before a single frame of exit animation 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, and it’s exactly the kind of React-state-mirroring-the-DOM machinery the previous lesson taught you to stop writing.

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

The keyframes for fading, zooming, and sliding aren’t ones you should be writing yourself. A small, CSS-first Tailwind v4 package called tw-animate-css ((A CSS-first Tailwind v4 utility pack providing enter/exit animation utilities; the maintained successor to the deprecated tailwindcss-animate.)) ships them, and new shadcn projects already depend on it. (If you’ve seen tailwindcss-animate in older code, this is its maintained successor, so reach for tw-animate-css in new work.) Installing it is a single line in app/globals.css, right after the Tailwind import:

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

That one 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.

The point to hold onto is the third mental model: 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. The library’s defaults respect the compositor budget by design, so you get smooth motion for free, without having to think about it.

Here’s the seam between React and CSS. The dialog primitive (shadcn builds these on top of Radix ((The headless component library, providing behavior and accessibility without styling, that shadcn wraps with Tailwind classes.)) ) sets a data-state attribute on the dialog’s content element: data-state="open" while it’s open, and 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, which is the entrance. When the state is closed, run animate-out with the reverse, which is the exit. The state lives in React, the motion lives in CSS, and the data-state attribute is the seam between them. React (through Radix) is responsible for flipping that one attribute, and CSS is responsible for everything that happens visually as a result. You write no animation JavaScript at all.

That leaves the exit-timing problem from a moment ago, and Radix handles it. When the dialog closes, Radix sets data-state="closed" and keeps the element mounted until the animate-out animation finishes, then removes it. The exit animation gets the frames it needs because Radix delays the unmount for you, which is why the pattern works without you writing any timing logic.

What’s worth seeing here is the seam in motion: one attribute flipping in React, and CSS responding. The following sequence scrubs a dialog through its full lifecycle. The left side of each step shows the DOM, meaning the content element and its current data-state. The right side 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.

Here is the literal className you’ll read in a shadcn DialogContent, walked through piece by piece. It’s a dense string, and the point of the walkthrough is to visually separate 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). This is the open half. 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 the close half, and it’s 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

There’s one common component where the cheap-property rule and the choreography pattern collide, and it’s worth knowing how shadcn resolves it, because the workaround is not obvious. An accordion grows in height as it opens, and height is exactly the kind of 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 cannot animate toward, because there’s no number for it to interpolate to. So 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, so you don’t write them) animate height from 0 to that measured variable. Radix supplies the number the browser was missing, and the keyframe animates to it. You copy this pattern from shadcn rather than building it, but it’s worth knowing what each piece does: Radix measures and sets the variable, and the keyframe animates the height to it.

You’ve now watched transforms do real work: the scale in a zoom, the translate in a slide, the -translate-x-1/2 centering the dialog. So this is the right moment to name the raw material directly, because it’s no longer an abstract list. It’s the set of utilities behind motion you already understand.

There are four transform utilities, and the first thing to know about all four is why they’re the animation primitives: every one is composite-only, in the cheap lane of the budget.

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

A few of these you’ll reach for so often they become second nature. hover:scale-105 is the card-lift, the subtle grow that signals “this is interactive.” active:scale-95 (or the 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 the gentle raise, an alternative to scaling. And rotate paired with a data-state, such as data-[state=open]:rotate-180 on a chevron, is how dropdown and accordion indicators flip to point the other way when they open, tying the indicator’s motion right back to the same data-state seam.

You may occasionally see transform-gpu, which explicitly hints to the browser that it should promote the element to its own compositor layer. It’s worth recognizing, but you’ll rarely reach for it by hand, because the browser already promotes animating transforms on its own, so you almost never need to ask.

prefers-reduced-motion: motion you can turn off

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

This section is not an accessibility footnote. The code conventions for this project put it plainly: every animation that’s visible enough to notice needs a reduced-motion escape, with no exceptions. That’s a standard worth holding to, so we treat it as one.

The reason is concrete and human. Some people get genuinely unwell from on-screen motion. The clearest case is a vestibular disorder, where the inner-ear balance system is disturbed by movement the body didn’t make, but motion sensitivity and attention needs matter too. For those users, a big sliding panel or a parallax scroll isn’t delightful, it can be nauseating or disorienting. So 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.

The habit to build is to pair your animations with a motion-reduce: escape that switches them off or down:

<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. They say: for users who asked for less motion, skip the tween, skip the keyframes, and snap straight to the final state.

Here is the nuance that’s easy to get wrong in both directions. Reduced motion does not mean no motion. It means cutting the decorative motion, such as the parallax, the big slides, and the attention-grabbing bounce, while keeping the functional motion that actually communicates something. A loading spinner under reduced motion should keep spinning, 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 the motion that’s there for flourish rather than for meaning. So this is 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 too: motion-safe:, which applies only when motion is allowed. It’s the opt-in version. Instead of adding motion and subtracting it for sensitive users, you withhold motion by default and add it only when the user hasn’t asked to reduce it. Either direction is valid, so pick whichever reads more clearly for the animation at hand.

The good news is that you don’t have to do this for everything yourself. shadcn and Radix components already ship sensible reduced-motion behavior, so the dialog you built earlier honors the setting without you touching it. The motion-reduce: discipline is your job specifically on the custom motion you author: the shake keyframe from a moment ago, or the hover-lift on a card you styled by hand. (This habit gets its proper home in the accessibility-baseline chapter later in the course; here you’re just installing it.)

Two quick checks before we look ahead. First, sort these properties by what they cost to animate, which is 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. Given a dialog that 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 here’s the reduced-motion nuance, since it’s 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 SaaS surface lives inside what you just learned: CSS transitions, keyframes, and tw-animate-css. But it’s worth naming the two things on the horizon so you recognize them and know they sit deliberately outside this lane, rather than being gaps in your knowledge.

The first is the View Transitions API ((A browser API that snapshots the page before and after a DOM change and animates the difference, including across navigations.)) . Everything in this lesson animates a single element changing. View Transitions animate the whole page changing between two states, including across a route change. You call document.startViewTransition(() => updateDOM()), the browser snapshots the before and after, and it cross-fades the difference. Next.js 16 integrates it behind an experimental.viewTransition: true flag in the config, paired with React 19.2’s <ViewTransition> component imported from react. Support is real but uneven: Chromium and Safari 18 handle same-document transitions, while Firefox is behind a flag as of early 2026, so verify support before you lean on it. You’re meeting it by name only, and the part to keep is the decision rule: reach for View Transitions when the animation spans a navigation or a large DOM swap that per-element CSS can’t choreograph. A later part of the course covers it properly if a project needs it.

The second is Framer Motion (now just called Motion), the React animation library from the very start of this lesson. It exists, it’s genuinely powerful for spring physics and gesture-driven motion, and it’s more than the surface this course ships needs, because CSS plus tw-animate-css covers the cases you’ll actually hit. Knowing where that threshold sits is part of the experienced engineer’s skill: you cross it rarely, for motion CSS truly can’t express, and you stay on the platform default the rest of the time. That’s the verdict the lesson opened with, now earned.