A transition is a rule about change
transition says: when this property changes, do not jump — take this long, with this easing. The change itself still comes from somewhere else: a hover, a class added by script, a media query.
.button {
background: #15161a;
transition: background 200ms ease 0ms;
/* property duration easing delay */
}
.button:hover { background: #2a2d35; }
Declare it on .button, not on .button:hover. On the hover rule it only applies going in, and the element snaps back when the pointer leaves.
Name the properties
all also makes the browser watch every property for change, and it will happily animate a height or a width you altered for another reason, costing a layout pass every frame.
/* lazy, and it will animate things you did not mean — including layout */
transition: all 200ms;
/* explicit, and each can have its own timing */
transition:
opacity 160ms ease,
transform 320ms cubic-bezier(.2, .7, .1, 1);
What is cheap to animate
/* forces layout on every frame */
.bad { transition: left 300ms; }
.bad:hover { left: 20px; }
/* compositor only */
.good { transition: translate 300ms; }
.good:hover { translate: 20px 0; }
| Property | Cost | Verdict |
|---|---|---|
transform | Compositor only | Animate freely. |
opacity | Compositor only | Animate freely. |
filter | Paint, often on the GPU | Usually fine. |
color, background-color | Paint | Fine for small areas. |
box-shadow | Paint, whole box | Animate a pseudo-element's opacity instead. |
width, height, margin, padding, top, left | Layout, every frame | Avoid. Use transform instead. |
Easing
- Something appearing should
ease-out: it decelerates into place, which reads as arriving. - Something disappearing should
ease-in: it accelerates away. - Nothing in an interface should be
linear. It reads as mechanical because nothing physical moves that way.
transition-timing-function: linear; /* constant — right for a spinner, wrong for UI */
transition-timing-function: ease-out; /* fast then slow — things ARRIVING */
transition-timing-function: ease-in; /* slow then fast — things LEAVING */
transition-timing-function: ease-in-out; /* both — movement between two places */
transition-timing-function: cubic-bezier(.2, .7, .1, 1); /* your own */
transition-timing-function: steps(6, end); /* discrete jumps */
Duration
Larger distances need longer durations to look natural, but the relationship is not linear — doubling the distance needs about 1.3× the time, not 2×.
- 100–150ms — a hover, a colour, a small state change. Anything slower feels laggy.
- 200–300ms — a panel opening, a card lifting, most interface motion.
- 300–500ms — a full-screen transition, a large element travelling a long way.
- Over 500ms the reader is waiting for your animation, not using your product.
Transitioning to and from display: none
An element with display: none cannot be animated — there is nothing to interpolate from. Two newer properties fix the problem that used to need JavaScript.
.panel {
opacity: 0;
translate: 0 -8px;
display: none;
transition: opacity 200ms, translate 200ms, display 200ms allow-discrete;
}
.panel.open {
opacity: 1;
translate: 0 0;
display: block;
}
/* the starting point for the entry, now that display is changing */
@starting-style {
.panel.open {
opacity: 0;
translate: 0 -8px;
}
}
allow-discrete lets display flip at the right end of the transition — on entry immediately, on exit at the finish. @starting-style gives the browser a "before" to animate from for an element that has just appeared.
Reduced motion is not optional
For some people large motion causes real nausea and vertigo. The setting exists; honouring it is three lines. Note that it means reduced, not none — a cross-fade is usually still fine, it is travel and parallax that cause the trouble.
@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;
}
}
Common mistakes
- Transitioning
height: auto. It does not work — there is no number to interpolate. Animategrid-template-rows: 0fr → 1fr, ormax-height, or usecalc-size()where it is supported. - Animating on page load. A transition defined before the first paint fires as the page arrives, and everything slides in at once.
- Forgetting the return journey. Hover in and hover out should usually differ: out can be faster.
- Animating the thing that moves the layout. If a card grows on hover, everything after it shifts — scale it with
transforminstead.