Transition Best Practices
The Problem
CSS transitions add polish and help users understand state changes. However, AI agents consistently make the same mistakes: transitioning expensive properties (like width, height, and margin) that trigger layout recalculations, using generic transition: all 0.3s ease declarations, and never considering the transition-behavior: allow-discrete property for transitioning display or other discrete properties. The result is janky animations, poor performance, and missed opportunities for smooth UI transitions.
The Solution
Transition only cheap properties (transform, opacity) whenever possible, use specific property lists instead of all, choose appropriate easing curves, and leverage transition-behavior: allow-discrete with @starting-style for entry/exit animations.
Performance Tiers
- Cheap (compositor-only):
transform,opacity— run on the GPU compositor thread, no layout or paint. - Moderate (paint-only):
background-color,color,box-shadow— trigger repaint but not layout. - Expensive (layout-triggering):
width,height,margin,padding,top,left— trigger full layout recalculation.
Code Examples
Transitioning the Right Properties
/* Good: transform and opacity are cheap */
.card {
transition: transform 0.2s ease, opacity 0.2s ease;
}
@media (hover: hover) {
.card:hover {
transform: translateY(-4px);
opacity: 0.95;
}
}
/* Bad: animating width triggers layout recalculation */
.card-bad {
transition: width 0.3s ease, height 0.3s ease;
}
Replace expensive property transitions with transform equivalents:
/* Instead of transitioning width */
.expandable {
transform: scaleX(0);
transform-origin: left;
transition: transform 0.3s ease;
}
.expandable.open {
transform: scaleX(1);
}
/* Instead of transitioning top/left */
.slide-in {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.slide-in.visible {
transform: translateX(0);
}
Be Specific — Avoid transition: all
/* Bad: transitions every property change, including unintended ones */
.element {
transition: all 0.3s ease;
}
/* Good: only transition what you intend */
.element {
transition: background-color 0.15s ease, transform 0.2s ease;
}
Choosing Easing Functions
/* Default ease — good general purpose */
.fade {
transition: opacity 0.2s ease;
}
/* ease-out — element arriving (enters fast, decelerates) */
.slide-enter {
transition: transform 0.3s ease-out;
}
/* ease-in — element leaving (starts slow, accelerates) */
.slide-exit {
transition: transform 0.3s ease-in;
}
/* ease-in-out — continuous motion (both ends decelerate) */
.move {
transition: transform 0.4s ease-in-out;
}
/* Custom cubic-bezier for a snappy, natural feel */
.bounce {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* Custom ease-out for UI interactions */
.interact {
transition: transform 0.2s cubic-bezier(0.25, 0.1, 0.25, 1);
}
Duration Guidelines
/* Micro-interactions: 100-200ms */
.button {
transition: background-color 0.15s ease;
}
/* State changes: 200-300ms */
.panel {
transition: transform 0.25s ease-out;
}
/* Complex or large animations: 300-500ms */
.modal-backdrop {
transition: opacity 0.35s ease;
}
Transitioning display with transition-behavior: allow-discrete
Traditionally, display: none cannot be transitioned because it is a discrete property. The transition-behavior: allow-discrete property, combined with @starting-style, changes this.
.tooltip {
/* Final visible state */
opacity: 1;
transform: translateY(0);
display: block;
/* Transition including discrete display change */
transition:
opacity 0.2s ease,
transform 0.2s ease,
display 0.2s allow-discrete;
/* Starting state for entry animation */
@starting-style {
opacity: 0;
transform: translateY(-4px);
}
}
.tooltip[hidden] {
/* Exit state */
opacity: 0;
transform: translateY(-4px);
display: none;
}
Popover Entry/Exit Animation
[popover] {
/* Final open state */
opacity: 1;
transform: translateY(0) scale(1);
transition:
opacity 0.25s ease,
transform 0.25s ease,
overlay 0.25s allow-discrete,
display 0.25s allow-discrete;
/* Entry animation starting state */
@starting-style {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
}
/* Exit state */
[popover]:not(:popover-open) {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
Dialog with Backdrop Transition
dialog {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.3s ease,
transform 0.3s ease,
overlay 0.3s allow-discrete,
display 0.3s allow-discrete;
@starting-style {
opacity: 0;
transform: translateY(16px);
}
}
dialog:not([open]) {
opacity: 0;
transform: translateY(16px);
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
opacity: 1;
transition:
opacity 0.3s ease,
display 0.3s allow-discrete;
@starting-style {
opacity: 0;
}
}
Staggered Transitions
.list-item {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.list-item.visible {
opacity: 1;
transform: translateY(0);
}
.list-item:nth-child(1) { transition-delay: 0ms; }
.list-item:nth-child(2) { transition-delay: 50ms; }
.list-item:nth-child(3) { transition-delay: 100ms; }
.list-item:nth-child(4) { transition-delay: 150ms; }
Common AI Mistakes
- Using
transition: all: Transitions every property, including layout-triggering ones, causing performance issues and unintended visual changes. - Animating expensive properties: Transitioning
width,height,margin,top,leftinstead of usingtransform(translate, scale). - Using
linearoreasefor everything: Not matching the easing to the interaction type. Entering elements should useease-out; exiting elements should useease-in. - Too-long durations: Using
0.5sor longer for simple state changes. Micro-interactions should be100-200ms. - Not knowing about
transition-behavior: allow-discrete: Using JavaScript to toggle classes with delays instead of transitioningdisplaywithallow-discreteand@starting-style. - Forgetting
@starting-style: Usingtransition-behavior: allow-discretewithout@starting-style, so the entry animation has no starting state to transition from. - Transitioning on page load: Not scoping transitions so they fire when the page first renders, causing distracting animations.
When to Use
- State changes: Hover, focus, active, open/closed states of interactive elements.
transformandopacity: Always prefer these for motion. They run on the compositor thread and never cause jank.transition-behavior: allow-discrete: Animatingdisplay: nonetodisplay: blockfor tooltips, popovers, dialogs, and dropdown menus.@starting-style: Defining the initial state of an element entering the page or becoming visible for the first time.- Not for complex sequences: Use CSS
@keyframesanimations for multi-step sequences. Transitions handle two-state changes.
References
- Using CSS Transitions — MDN
- transition-behavior — MDN
- An Interactive Guide to CSS Transitions — Josh W. Comeau
- Ten Tips for Better CSS Transitions and Animations — Josh Collinsworth
- Transitioning Top-Layer Entries and the Display Property — Smashing Magazine
- Four New CSS Features for Smooth Entry and Exit Animations — Chrome for Developers