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.
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:
| Class | Properties it transitions |
|---|---|
| transition | colours (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-colors | the colour properties and gradient stops only |
| transition-opacity | opacity |
| transition-shadow | box-shadow |
| transition-transform | transform, translate, scale, rotate |
| transition-all | all (every property that changes) |
| transition-none | none, with no duration set |
| transition-[height] | whatever you write (measured: height) |
| transition-(--my) | a CSS variable holding the list (measured with --my: width: width) |
transition does not animate width or heightwidth, 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:160pxat 300ms. It jumped; there was no animation at all.transition-all duration-1000:124.5pxat 300ms,160pxat the end. It animated.transition-[width] duration-1000:121.156pxat 300ms,160pxat the end. It animated.
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:
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:
| Class | CSS property it sets | Measured |
|---|---|---|
| scale-110 | scale | scale: 1.1; a 40px box measured 44 × 44px |
| scale-x-150 | scale | scale: 1.5 1 |
| rotate-45 | rotate | rotate: 45deg; the bounding box of a 40px square grew to 56.57 × 56.57px |
| translate-x-4 -translate-y-2 | translate | translate: 16px -8px (spacing scale) |
| translate-x-1/2 | translate | 50% of the element's own width |
| skew-x-12 | transform | matrix(1, 0, 0.212557, 1, 0, 0) |
| origin-top-left | transform-origin | 0px 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.
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):
| Class | Measured | Use for |
|---|---|---|
| animate-spin | spin, 1s, linear, infinite | loading spinners |
| animate-ping | ping, 1s, cubic-bezier(0, 0, 0.2, 1), infinite | a notification dot that radiates outwards |
| animate-pulse | pulse, 2s, cubic-bezier(0.4, 0, 0.6, 1), infinite | skeleton placeholders while content loads |
| animate-bounce | bounce, 1s, infinite | a "scroll down" arrow |
| animate-none | animation: none | turning 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:
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:
| Classes | Normal | Reduced motion |
|---|---|---|
| motion-safe:animate-spin | animation-name: spin | none |
| animate-spin motion-reduce:animate-none | spin | none |
| transition … motion-reduce:transition-none | the full property list | transition-property: none |
| transition ease-linear motion-safe:hover:translate-x-10 | mid-hover translate: 17.3358px (animating) | hover does nothing: translate: none |
| transition ease-linear hover:translate-x-10 motion-reduce:transition-none | animates | jumps 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.
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
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.
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.
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.
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 andcubic-bezier(0.4, 0, 0.2, 1) - Plain
transitiondoes not animate width/height: usetransition-[width]ortransition-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-discreteallows discrete properties likedisplaywill-change-transformetc.: a last resort for a measured problem- Reduced motion:
motion-safe:(no-preference) andmotion-reduce:(reduce);motion-reduce:transition-nonegives the end state instantly,motion-safe:removes the effect
@apply, and a landing page built from scratch.