Transitions, transforms and animation
Motion that supports the interface rather than decorating it: transitions, transforms, keyframes in the theme, and reduced-motion handling.
Transitions
<button class="rounded-md bg-sky-600 px-4 py-2 text-white
transition-colors duration-150 ease-out
hover:bg-sky-500 active:bg-sky-700">
Save
</button>
<div class="opacity-0 transition-opacity duration-200 group-hover:opacity-100">Revealed</div>
<div class="scale-95 opacity-0 transition duration-200 ease-out
data-[state=open]:scale-100 data-[state=open]:opacity-100">
Popover
</div>| Utility | Transitions |
|---|---|
transition | Colours, opacity, shadow, transform - the common set |
transition-colors | Colour properties only |
transition-transform | Transforms only (runs off the main thread) |
transition-all | Everything, including properties you did not intend |
duration-150 | Milliseconds |
ease-out | Fast start, gentle finish - the usual choice |
delay-100 | A delay before the transition starts |
- Prefer a named transition set over
transition-all. It is cheaper, and it prevents an unrelated property from animating when you add a class later. - 150ms to 200ms is the range where an interface feels responsive. Above 300ms, interactions start to feel sluggish even on a fast machine.
transition-transformis the only one that can be handed to the compositor, so it stays smooth while the main thread is busy.
⚠️
Animate
transform and opacity where you can. Animating width, height, top or margin forces layout on every frame, which is what turns a simple drawer into a stuttering one on a mid-range phone.Transforms
<div class="translate-x-0 transition-transform hover:translate-x-2">Nudge right</div>
<div class="rotate-3 transition-transform hover:rotate-0">Straighten on hover</div>
<div class="scale-100 transition-transform hover:scale-105 active:scale-95">Pressable</div>
<div class="origin-top-left rotate-45">Rotate around a corner</div>
<!-- combining several transforms: they compose through the same variables -->
<div class="translate-y-1 rotate-2 scale-105">Composed</div>
<!-- a centred overlay without knowing its size -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">Centered</div><!-- a slide-in drawer -->
<aside class="fixed inset-y-0 right-0 w-80 translate-x-full
transition-transform duration-300 ease-out
data-[open=true]:translate-x-0">
...
</aside>| Utility | CSS |
|---|---|
translate-x-2 | translate: 0.5rem 0 |
-translate-y-1/2 | Negative half of the element's own height |
rotate-45 | rotate: 45deg |
scale-105 | scale: 1.05 |
skew-y-3 | skew: 0 3deg |
origin-top-right | transform-origin at the corner |
<!-- a skeleton that respects motion preferences -->
<div class="animate-pulse motion-reduce:animate-none">Loading...</div>
<!-- and a spinner that stops moving for reduced motion -->
<svg class="size-5 animate-spin motion-reduce:animate-none">...</svg>Keyframes defined in the theme
@import "tailwindcss";
/* a custom animation is a theme variable plus its keyframes, in one place */
@theme {
--animate-wiggle: wiggle 1s ease-in-out infinite;
--animate-fade-in-up: fade-in-up 250ms ease-out both;
@keyframes wiggle {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}<div class="animate-wiggle motion-reduce:animate-none">Shake</div>
<div class="animate-fade-in-up">Appears</div>
<!-- the built-in ones -->
<div class="animate-spin"></div>
<div class="animate-ping"></div>
<div class="animate-pulse"></div>
<div class="animate-bounce"></div>- Declaring
--animate-*in@themeproduces theanimate-*utility automatically. - Keep the keyframes next to the token that uses them, so deleting the animation deletes the definition too.
- Add
motion-reduce:animate-noneat every use site, or amotion-reducerule in the component - an endlessly wiggling element is exactly what the preference exists to stop. - Use
bothin the shorthand when the animation should hold its end state; without it the element snaps back when the animation finishes. - Keep an entrance animation under about 300ms, and never block interaction on it.
/* one place to honour reduced motion for everything */
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}FAQ
Why is my transition not running?
The element must have a
transition-* utility and a state that changes the property, and the property must not be display - a transition cannot run between display: none and block. Use opacity, visibility or a scale transform instead.How much animation is too much?
When it delays the user. Motion should explain where something came from or confirm an action, and it should finish in under about 300 milliseconds. Anything longer, or anything that repeats without a reason, will be the first thing a user turns off - and the first thing that makes a slow device feel worse.
Related
Responsive and state variants Production performance and the honest limits
Last refreshed 2026-09-18.