Transitions
Animate property changes with transition utilities — which properties to transition, durations that feel right, easing curves, and respecting reduced motion.
A transition smoothly interpolates a property when its value changes. In Tailwind, that means adding one class to the base state, not the hover state.
Overview#
<div class="flex gap-4 p-6">
<button class="rounded-md bg-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-300">
No transition
</button>
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition duration-200 hover:bg-indigo-500">
With transition
</button>
</div>What to transition#
| Utility | Transitions |
|---|---|
transition | Colours, opacity, shadow, transform, filter, backdrop-filter |
transition-all | Every animatable property |
transition-colors | Colour properties only |
transition-opacity | Opacity |
transition-shadow | Box shadow |
transition-transform | Transform |
transition-none | Disable |
transition-[width,height] | An explicit list |
Duration#
| Utility | Time | Use for |
|---|---|---|
duration-75 | 75ms | Micro-feedback |
duration-150 | 150ms | Colour and opacity changes |
duration-200 | 200ms | Buttons, links — the default sweet spot |
duration-300 | 300ms | Dropdowns, small panels |
duration-500 | 500ms | Modals, drawers |
duration-700+ | 700ms+ | Decorative only |
Easing#
| Utility | Curve | Feels like |
|---|---|---|
ease-linear | Constant | Mechanical — for spinners and progress |
ease-in | Slow start | Exits |
ease-out | Fast start, slow end | Entrances — the most useful default |
ease-in-out | Slow both ends | Movement between two states |
@theme {
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
}<div class="transition duration-300 ease-snappy">…</div>Delay#
<div class="transition delay-150 duration-300">…</div>Delays are most useful for staggering:
<li class="transition delay-0">…</li>
<li class="transition delay-75">…</li>
<li class="transition delay-150">…</li>Performance#
Only two properties are cheap to animate, because the compositor can handle them without touching layout or paint:
| Property | Cost | Notes |
|---|---|---|
transform | Cheap | Move, scale, rotate |
opacity | Cheap | Fade |
color, background-color | Moderate | Repaints |
box-shadow | Expensive | Repaints a large area |
filter, backdrop-filter | Expensive | Re-composites every frame |
width, height, top, margin | Very expensive | Reflows the whole document |
<!-- ❌ Triggers layout on every frame -->
<div class="w-32 transition-all hover:w-40">…</div>
<!-- ✅ Compositor-only -->
<div class="transition-transform hover:scale-110">…</div>Reduced motion#
Some people get motion sickness from animation, and the OS exposes that preference. Honour it.
<div class="transition duration-300 motion-reduce:transition-none">…</div>For a project-wide default, disable non-essential motion in your base layer:
@layer base {
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}Entry animations with starting#
v4 The starting variant maps to @starting-style, which lets an element transition in when it first appears — including from display: none.
<div popover id="menu"
class="opacity-100 transition-all transition-discrete duration-200
starting:open:opacity-0 starting:open:scale-95">
…
</div>transition-discrete (transition-behavior: allow-discrete) is what makes display itself animatable, so the element can fade out before it disappears.
Common patterns#
Button#
<button class="rounded-md bg-indigo-600 px-4 py-2 font-medium text-white transition duration-200 hover:bg-indigo-500 active:scale-95">
Save
</button>Card lift#
<a class="block rounded-xl border p-6 transition duration-200 hover:-translate-y-1 hover:shadow-lg">…</a>Reveal on group hover#
<div class="p-4">
<div class="group mx-auto flex max-w-sm items-center justify-between rounded-lg border border-gray-200 p-4">
<span class="text-sm text-gray-900">Hover this row</span>
<button class="rounded px-2 py-1 text-xs font-medium text-indigo-600 opacity-0 transition duration-200 group-hover:opacity-100 focus-visible:opacity-100">
Edit
</button>
</div>
</div>Note focus-visible:opacity-100 — without it the button is unreachable by keyboard.
Accordion height#
Animating height: auto is not possible, but grid-template-rows is:
<div class="grid grid-rows-[0fr] transition-all duration-300 data-[open=true]:grid-rows-[1fr]">
<div class="overflow-hidden">…content…</div>
</div>Underline that grows from the left#
<div class="p-4">
<a href="#" class="group relative inline-block text-sm font-medium text-gray-900">
Read the announcement
<span class="absolute -bottom-0.5 left-0 h-0.5 w-full origin-left scale-x-0 bg-indigo-600 transition-transform duration-300 group-hover:scale-x-100"></span>
</a>
</div>Scaling a full-width bar is cheaper than animating width.
Common mistakes#
hover:transition. Puttransitionon the base state.transitionwith noduration. The default is 150ms, which is fine — but be explicit when it matters.transition-alleverywhere. It picks up layout properties and gets slow.- Animating
width,height,topormargin. Usetransform. - Ignoring
prefers-reduced-motion. It is an accessibility requirement, not a nicety. - Hover-only reveals. Anything revealed on hover must also appear on focus, or keyboard users cannot reach it.
- Transitioning an element that also has
hidden.displayis discrete; you needtransition-discreteandstarting:.
Related topics#
- Transforms — the cheap property to animate
- Animation — keyframes and looping motion
- States & Variants — what triggers the change
- Theme & Design Tokens — custom
--ease-*curves