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>
UtilityTransitions
transitionColours, opacity, shadow, transform - the common set
transition-colorsColour properties only
transition-transformTransforms only (runs off the main thread)
transition-allEverything, including properties you did not intend
duration-150Milliseconds
ease-outFast start, gentle finish - the usual choice
delay-100A 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-transform is 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>
UtilityCSS
translate-x-2translate: 0.5rem 0
-translate-y-1/2Negative half of the element's own height
rotate-45rotate: 45deg
scale-105scale: 1.05
skew-y-3skew: 0 3deg
origin-top-righttransform-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>
  1. Declaring --animate-* in @theme produces the animate-* utility automatically.
  2. Keep the keyframes next to the token that uses them, so deleting the animation deletes the definition too.
  3. Add motion-reduce:animate-none at every use site, or a motion-reduce rule in the component - an endlessly wiggling element is exactly what the preference exists to stop.
  4. Use both in the shorthand when the animation should hold its end state; without it the element snaps back when the animation finishes.
  5. 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.

Responsive and state variants Production performance and the honest limits

Last refreshed 2026-09-18.