09 · Transitions, Transforms & Animation¶
Motion makes an interface feel responsive: a button that darkens smoothly, a menu that
fades in, a card that lifts on hover. Tailwind's motion utilities are thin wrappers
around CSS transitions, transforms and animations, but v4 changed how transforms work
under the hood, and that change explains a bug you'll probably meet if you mix Tailwind
with hand-written CSS. This lesson covers the utilities, the change, entry animations
with starting:, your own keyframes, and respecting users who turn motion off.
Transitions¶
A transition animates a property from its old value to its new one whenever it changes.
You need two things: something that changes (usually a variant like hover:) and a
transition-* utility that says which properties to animate:
<a class="rounded-md bg-sky-700 px-4 py-2 text-white transition-colors duration-200
hover:bg-sky-800">Continue</a>
| Utility | transition-property |
|---|---|
transition |
a sensible default list: colours, opacity, box-shadow, transform, translate, scale, rotate, filters, display, … |
transition-colors |
colour properties only (color, background-color, border-color, fill, …) |
transition-opacity |
opacity |
transition-transform |
transform, translate, scale, rotate |
transition-shadow |
box-shadow |
transition-all |
all |
transition-[height] |
whatever you put in brackets |
Duration, easing and delay are separate: duration-200, ease-out, delay-150. Without
them, transitions use the theme defaults, --default-transition-duration: 150ms and
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1).
Prefer the narrow utilities. transition-all animates every property that changes,
including ones you didn't intend (a width that changes at a breakpoint, a colour change
from switching themes), and makes the browser check every property on every change.
Transforms in v4: separate properties¶
In v3, translate-x-4, scale-95 and rotate-45 all wrote to the one transform
property. v4 uses the individual CSS properties translate, scale and rotate:
.translate-x-4 {
--tw-translate-x: calc(var(--spacing) * 4);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.scale-95 { /* --tw-scale-x/y/z: 95% */ scale: var(--tw-scale-x) var(--tw-scale-y); }
.rotate-45 { rotate: 45deg; }
(Skews and 3D rotations like rotate-x-12 still use transform, because there are no
individual properties for them.)
The independent properties compose cleanly: a hover:scale-105 doesn't wipe out a
-translate-y-1/2 on the same element, because they're different properties.
The bug this causes¶
If you write your own CSS with transition-property: transform, a Tailwind scale-*
change won't animate, because the property that changed is scale, not transform.
We tested two elements, one with transition-[transform] and one with
transition-transform, and added scale-150 to both. 100ms later:
transition-[transform] running animations: 0 scale: 1.5 (jumped instantly)
transition-transform running animations: 1 scale: 1.01293 (animating)
Use transition-transform (or transition), which lists all four properties. The same
applies to any third-party CSS or JavaScript animation library that assumes Tailwind
uses transform.
Easing that feels right¶
ease-out(fast start, slow end) for things entering: menus, toasts, dialogs. They respond instantly and settle gently.ease-infor things leaving.ease-in-outfor things moving from one place to another on screen.- Keep UI transitions short: roughly 100–300ms. Longer feels sluggish for something the user triggered. Big, decorative motion can be longer.
Custom curves go in the theme as --ease-* tokens (lesson 01), e.g.
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); gives ease-snappy.
Entry animations with starting:¶
Transitions only run when a value changes. An element that appears (inserted into the
DOM, or switched from display: none) has no "before" value, so it just pops in. CSS's
@starting-style provides that before value, and Tailwind exposes it as starting::
We tested two hidden elements, both opacity-100 transition-opacity duration-1000, one
with starting:opacity-0, and switched both from hidden to block. 100ms later:
with starting:opacity-0 running animations: 1 opacity: 0.0196 (fading in)
without running animations: 0 opacity: 1 (popped in)
To animate an element out to display: none as well (popovers, dialogs), the
transition must include display, and the browser must be allowed to transition a
discrete property, which is what transition-discrete (transition-behavior:
allow-discrete) does:
<div id="menu" popover
class="rounded-lg bg-white p-2 shadow-lg ring-1 ring-black/5
opacity-0 scale-95 transition transition-discrete duration-150 ease-out
open:opacity-100 open:scale-100
starting:open:opacity-0 starting:open:scale-95">
…
</div>
<button popovertarget="menu">Options</button>
open: matches :popover-open (and [open] for <details>/<dialog>), and
starting:open: is the state it starts from on the way in. For the exit to animate,
display and overlay (which keeps a popover in the top layer while it fades out) must
be in the transition list. The default transition utility's list in 4.3.3 includes
both, along with opacity and scale, so it covers this case without
transition-all. Browser support for @starting-style and discrete transitions is
recent; without it, the menu simply appears and disappears instantly, which is a fine
fallback.
Animations and keyframes¶
Four animations ship in the default theme:
| Utility | Defined as | Typical use |
|---|---|---|
animate-spin |
spin 1s linear infinite |
Loading spinners |
animate-ping |
ping 1s cubic-bezier(0, 0, 0.2, 1) infinite |
Notification dots |
animate-pulse |
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite |
Skeleton screens |
animate-bounce |
bounce 1s infinite |
"Scroll down" hints |
Your own go in @theme, with the keyframes alongside (emitted only if the animation is
used):
@theme {
--animate-slide-up: slide-up 0.4s var(--ease-out) both;
@keyframes slide-up {
from { opacity: 0; translate: 0 0.75rem; }
to { opacity: 1; translate: 0 0; }
}
}
<li class="animate-slide-up">…</li>
<li class="animate-slide-up [animation-delay:60ms]">…</li>
<li class="animate-slide-up [animation-delay:120ms]">…</li>
both keeps the from state during the delay and the to state afterwards. Note that
delay-* utilities set transition-delay, not animation-delay, which is why the
stagger uses arbitrary properties.
Reduced motion¶
Some people get dizzy or nauseous from motion, and operating systems have a "reduce
motion" setting. Respect it. Two variants map to prefers-reduced-motion:
motion-reduce:applies when the user asked for less motion.motion-safe:applies only when they haven't.
<!-- Opt out: animate by default, stop for reduced motion -->
<svg class="size-5 animate-spin motion-reduce:animate-none" …></svg>
<!-- Opt in: only animate when it's known to be OK -->
<div class="motion-safe:transition motion-safe:hover:-translate-y-1">…</div>
motion-safe: (opt in) is the safer default for decorative motion like hover lifts and
entrance animations. For a spinner, stopping it entirely can make the page look frozen.
Replace it with something static that still communicates "loading", for example
motion-reduce:animate-none plus visible "Loading…" text.
Reduced motion doesn't mean no motion. Fades and colour changes are usually fine; it's large movement, zooming and parallax that cause problems.
Worked example: a card that lifts on hover¶
<a href="/guides/containers"
class="group block rounded-xl border border-gray-200 bg-white p-5
motion-safe:transition motion-safe:duration-200 motion-safe:ease-out
hover:border-gray-300 hover:shadow-lg motion-safe:hover:-translate-y-0.5
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600">
<h3 class="font-semibold text-gray-900">Container queries</h3>
<p class="mt-1 text-sm text-gray-600">Make components respond to their own width.</p>
<span class="mt-3 inline-flex items-center gap-1 text-sm font-medium text-sky-700">
Read guide
<span aria-hidden="true" class="motion-safe:transition-transform
motion-safe:group-hover:translate-x-0.5">→</span>
</span>
</a>
The border and shadow change for everyone (they're not motion). The lift and the arrow
nudge only happen for users who haven't asked for reduced motion. Animating
translate and box-shadow is cheap; avoid animating width, height, top or
margin, which force layout on every frame.
How It Actually Works¶
A transition is a rule the browser checks whenever a computed style changes: if the
property is listed in transition-property, it interpolates between the old and new
values over the duration. Tailwind's duration-* and ease-* utilities set both the
property and a --tw-duration / --tw-ease variable, and the transition utilities
read var(--tw-duration, var(--default-transition-duration)). That's why
duration-300 transition works in either class order.
The transform utilities set --tw-translate-x, --tw-scale-x and similar variables and
then the individual translate / scale properties, so combining translate-x-4 and
-translate-y-1/2 fills both variables of one translate value. The variables are
registered with @property (you can see the @property --tw-scale-x blocks in any
compiled file) with inherits: false and an initial value, so a parent's
--tw-scale-x never leaks into a child that has its own transform utilities.
@starting-style defines the styles an element has "before" its first style
computation. When the element first renders (or leaves display: none), the browser
transitions from the starting style to the normal style.
Common mistakes¶
transition-property: transformin your own CSS for Tailwindscale-/rotate-/translate-changes. Usetransition-transform.transition-alleverywhere, which animates things you didn't mean to.- Using
delay-*for an animation. It setstransition-delay; use[animation-delay:…]. - Expecting an element to animate in from
display: nonewithoutstarting:. - Animating layout properties (
width,height,top) for hover effects. - Ignoring reduced motion, or stopping a spinner with nothing to replace it.
- Long durations on UI feedback. Keep them under about 300ms.
Exercise¶
- Build a button with
transition-colorsand a hover colour, and try different durations until it feels instant but smooth. - Recreate the
transition-[transform]bug withhover:scale-105, then fix it. - Build the popover menu and check it fades in and out. Then test it in a browser
without
@starting-stylesupport (or temporarily remove thestarting:classes) and confirm it still works. - Add a custom
slide-upanimation with a staggered delay to a list of five items. - Turn on "reduce motion" in your operating system and check every animation on your page.