Motion

Tailwind CSS Fundamentals

Chapter 11 · Transitions, Transforms and Animation

Motion in CSS comes in three kinds, and Tailwind has a small set of classes for each. A transition smooths a change between two states (hover, focus, a class being toggled). A transform moves, scales, rotates or skews an element without disturbing the layout around it. An animation runs on its own, from keyframes, with no state change needed. The last part of the chapter is the one that matters most for real users: some people ask their system to reduce motion, and Tailwind has two variants for respecting that.

Every example here was run
Every class was compiled with Tailwind CSS 4.3.3 and the generated CSS read. The behaviours were measured in headless Chrome, by hovering with real mouse input, sampling the computed value part-way through a transition, inserting elements while the page was running, and emulating the system's reduced-motion setting. Values sampled mid-transition depend on exact timing, so yours will differ by a few units; the point is that they sit between the start and end values.

Transitions

A transition needs two things: a property list (what is allowed to animate) and a duration. Tailwind's transition-* classes set the property list and, apart from transition-none, also set a default duration of 150ms and the easing cubic-bezier(0.4, 0, 0.2, 1). Measured from the computed style:

ClassProperties it transitions
transitioncolours (color, background-color, borders, outline, text-decoration, fill, stroke, gradient stops), opacity, box-shadow, transform, translate, scale, rotate, filter, backdrop-filter, display, content-visibility, overlay, pointer-events
transition-colorsthe colour properties and gradient stops only
transition-opacityopacity
transition-shadowbox-shadow
transition-transformtransform, translate, scale, rotate
transition-allall (every property that changes)
transition-nonenone, with no duration set
transition-[height]whatever you write (measured: height)
transition-(--my)a CSS variable holding the list (measured with --my: width: width)
Plain transition does not animate width or height
The list above has no width, height, margin or padding in it. I hovered three boxes that grow from 40px to 160px, each with a 1000ms duration, and read the width 300ms in:
  • transition duration-1000: 160px at 300ms. It jumped; there was no animation at all.
  • transition-all duration-1000: 124.5px at 300ms, 160px at the end. It animated.
  • transition-[width] duration-1000: 121.156px at 300ms, 160px at the end. It animated.
Use transition-[width] (or transition-all for quick work) when the size changes. Animating layout properties is also the most expensive kind, which is why transforms below are usually the better choice.

Duration, Delay and Easing

duration-300 is transition-duration: 300ms, and any whole number works (duration-[1s,15s] gives a separate time per property). delay-200 waits before starting. The easing classes come from theme variables: ease-in, ease-out and ease-in-out are the three curves, and ease-linear is linear. For a custom curve write it in brackets: ease-[cubic-bezier(.2,.8,.2,1)]. One measured example:

<button class="bg-blue-500 text-white p-2 transition-colors duration-300 ease-out hover:bg-red-500">Save</button>
transition-property: color, background-color, border-color, … (the colour list) transition-duration: 0.3s transition-timing-function: cubic-bezier(0, 0, 0.2, 1) (ease-out) background-color, sampled while hovering: start oklch(0.623 0.214 259.815) blue-500 after 100ms oklab(0.63661 0.207186 0.0927007) an in-between colour after 600ms oklch(0.637 0.237 25.331) red-500

The in-between colour in the middle sample is the proof that the browser is blending, not jumping. Delay and duration work together: transition-opacity delay-200 duration-700 ease-in-out measured opacity, 0.2s delay, 0.7s duration.

Transitions are written on the element that changes, and the end state is whatever variant applies: hover:rotate-0 on an element that starts at -rotate-6 (with duration-1000) measured -4.80862deg at 100ms, then 0deg.

Transforms

In Tailwind v4 the transform utilities use the modern individual CSS properties, so they combine freely without one class overriding another, and each is transitioned separately. From the compiled output and the computed styles:

ClassCSS property it setsMeasured
scale-110scalescale: 1.1; a 40px box measured 44 × 44px
scale-x-150scalescale: 1.5 1
rotate-45rotaterotate: 45deg; the bounding box of a 40px square grew to 56.57 × 56.57px
translate-x-4 -translate-y-2translatetranslate: 16px -8px (spacing scale)
translate-x-1/2translate50% of the element's own width
skew-x-12transformmatrix(1, 0, 0.212557, 1, 0, 0)
origin-top-lefttransform-origin0px 0px instead of the default centre 20px 20px

Two properties of transforms explain why they are preferred for motion. They do not change the element's place in the layout (its neighbours stay where they were), and browsers can animate them without recomputing the page layout. Add transition-transform (or plain transition) and a hover: variant and you have the standard lift, grow or slide effect. Variants combine: hover:-translate-y-0.5, md:rotate-0, group-hover:scale-105.

3D Transforms

Rotating around the X or Y axis needs depth, which in CSS comes from perspective on the parent (the distance from the viewer), and children only share a 3D space if transform-style: preserve-3d is set (transform-3d). The built-in perspectives are named distances; I read them from the compiled theme: perspective-midrange is 800px and perspective-distant is 1200px.

<div class="perspective-distant"> <div class="size-10 bg-blue-500 transform-3d rotate-x-45 rotate-y-12 translate-z-4"></div> </div> <div class="size-10 bg-blue-500 backface-hidden rotate-y-180"></div>
parent perspective: 1200px child transform-style: preserve-3d child translate: 0px 0px 16px child transform: matrix3d(0.978148, 0.147016, -0.147016, 0, 0, 0.707107, 0.707107, 0, …) backface-hidden: backface-visibility: hidden perspective-origin-top: perspective-origin: top (measured 20px 0px)

backface-hidden is what makes a "flip card" work: put the front and back faces back to back, each with backface-hidden, and rotate the card 180 degrees on the Y axis. The matrix above shows the 3D rotation is really being applied; I did not build a full flip card for this chapter.

Animations

The four built-in animations are classes that set animation: var(--animate-…); the theme variables hold the real shorthand. I measured each element's animation-name, duration, easing and loop count, and asked the browser how many animations it was running (getAnimations().length):

ClassMeasuredUse for
animate-spinspin, 1s, linear, infiniteloading spinners
animate-pingping, 1s, cubic-bezier(0, 0, 0.2, 1), infinitea notification dot that radiates outwards
animate-pulsepulse, 2s, cubic-bezier(0.4, 0, 0.6, 1), infiniteskeleton placeholders while content loads
animate-bouncebounce, 1s, infinitea "scroll down" arrow
animate-noneanimation: noneturning one off at a breakpoint: animate-none md:animate-spin

Each of the four ran (getAnimations().length was 1). The responsive form works as expected: animate-none md:animate-spin measured none at a 700px viewport and spin at 900px.

For your own animation, define it in @theme together with its keyframes (the technique from chapter 9: --animate-wiggle plus @keyframes wiggle) and use animate-wiggle. You can also write the shorthand in brackets, animate-[wiggle_1s_ease-in-out_infinite], and it reads the animation name and settings correctly (measured wiggle, 1s, ease-in-out, infinite) — but the bracket form only names the animation. On a test page that had no @keyframes wiggle anywhere, the browser reported 0 running animations. If an arbitrary animation does nothing, check that the keyframes exist.

Enter Animations with starting:

A transition needs a before and after, and a brand-new element has no "before". The starting: variant wraps its styles in CSS @starting-style, which supplies the first-frame values for an element's first render (or when it goes from display: none to visible). I compiled starting:opacity-0 and the output is exactly @starting-style { .starting\:opacity-0 { opacity: 0%; } }. Then I inserted an element into the running page with JavaScript and read its opacity:

<div class="opacity-100 starting:opacity-0 transition-opacity duration-500">…</div>
inserted with starting:opacity-0 opacity at 0ms: 0 at 150ms: 0.367318 at 700ms: 1 inserted WITHOUT it opacity at 0ms: 1 at 150ms: 1 (it just appears)

The official docs show it on a popover: class="opacity-0 starting:open:opacity-0 …", combined with open:. I did not run that popover example; what I tested is the insertion case above. Because the default transition list includes display, and transition-discrete sets transition-behavior: allow-discrete (measured), the pieces for fading something in from hidden without JavaScript timers are in place; I did not test a display transition in this chapter. The docs' page for starting gives no browser-support notes, so check a support table before depending on it for something important.

will-change

will-change-transform, will-change-scroll (scroll-position), will-change-contents, will-change-auto, and the bracket form will-change-[top,left] (measured top, left) tell the browser in advance what is about to change so it can prepare. The official guidance is to use them as a last resort for a performance problem you have actually measured, not everywhere "just in case"; applied broadly they can make a page slower. If you need one, add it just before the change and go back to will-change-auto afterwards.

Respecting Reduced Motion

Some users get dizzy or sick from large movement and turn on "reduce motion" in their operating system. Browsers report this through prefers-reduced-motion, and Tailwind gives you the variants motion-safe: (no-preference) and motion-reduce: (reduce). I emulated the setting in Chrome and read the results:

ClassesNormalReduced motion
motion-safe:animate-spinanimation-name: spinnone
animate-spin motion-reduce:animate-nonespinnone
transition … motion-reduce:transition-nonethe full property listtransition-property: none
transition ease-linear motion-safe:hover:translate-x-10mid-hover translate: 17.3358px (animating)hover does nothing: translate: none
transition ease-linear hover:translate-x-10 motion-reduce:transition-noneanimatesjumps straight to 40px: the move happens, the animation does not

The last two rows are two different design decisions. With motion-safe: in front of the effect itself, reduced-motion users never get the movement. With motion-reduce:transition-none, they get the end state instantly, which is usually what you want when the change carries information (an item that moved, a panel that opened). Decorative loops such as animate-pulse and animate-bounce are the strongest candidates for motion-safe:. The docs' own pairing is duration-300 motion-reduce:duration-0.

A sensible default
Prefer transition-colors, transition-opacity and transition-transform to transition-all: they say what moves, avoid accidentally animating something you did not intend, and keep the browser's work small. Keep durations short (150–300ms for interface feedback), put decorative looping animations behind motion-safe:, and never rely on motion alone to convey something.

Hands-On Exercises

Exercise 1

Build a button that turns from blue to red on hover over 300ms with an ease-out curve and show, by sampling the colour part-way, that it is blending. Then make a box grow from 40px to 160px wide on hover with three different transition classes (transition, transition-all, transition-[width]) and show which one fails to animate, and why.

📄 View solution
Exercise 2

Make seven small squares showing scale, rotate, translate, skew and a changed origin, and read the computed property for each. Show that rotating a 40px square increases its bounding box. Then build the 3D case: a parent with perspective, a child with transform-3d rotated on X and Y and moved on Z, and a backface-hidden element rotated 180 degrees.

📄 View solution
Exercise 3

Show all four built-in animations are running, make one respect reduced motion in two different ways (with motion-safe: and with motion-reduce:) and prove both by emulating the setting, then fade in an element inserted by JavaScript using starting: and compare it with the same element without. Finally write an arbitrary animate-[…] with no keyframes and show the browser runs nothing.

📄 View solution

Chapter 11 Quick Reference

  • Transitions: transition (colours, opacity, shadow, transforms, filters, display…), transition-colors / -opacity / -shadow / -transform / -all / -none, transition-[prop], transition-(--var); default 150ms and cubic-bezier(0.4, 0, 0.2, 1)
  • Plain transition does not animate width/height: use transition-[width] or transition-all
  • duration-300, delay-200, ease-in / ease-out / ease-in-out / ease-linear, ease-[cubic-bezier(…)]
  • Transforms are the individual properties: scale-110, rotate-45, translate-x-4, skew-x-12, origin-top-left; they do not move neighbours in the layout
  • 3D: perspective-midrange (800px) / perspective-distant (1200px) on the parent, transform-3d, rotate-x-45, rotate-y-12, translate-z-4, backface-hidden
  • Animations: animate-spin / -ping / -pulse / -bounce / -none; custom ones need @keyframes (in @theme); animate-[…] without keyframes does nothing
  • starting:opacity-0 = @starting-style: the first-frame value so a newly inserted element can transition in; transition-discrete allows discrete properties like display
  • will-change-transform etc.: a last resort for a measured problem
  • Reduced motion: motion-safe: (no-preference) and motion-reduce: (reduce); motion-reduce:transition-none gives the end state instantly, motion-safe: removes the effect
Coming next
Tailwind CSS Fundamentals 12 is the capstone, Building Real Components: buttons, cards, a form with validation states, a responsive navbar and a modal, why you reuse with components rather than @apply, and a landing page built from scratch.