Transitions

Move things between two states smoothly, cheaply, and only when it helps.

18of 26
≈ 10Min read
8Parts

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.

the four parts
.button {
  background: #15161a;
  transition: background 200ms ease 0ms;
  /*          property   duration easing delay */
}

.button:hover { background: #2a2d35; }
Put the transition on the resting state

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.

never transition: all
/* 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

the same movement, two costs
/* forces layout on every frame */
.bad { transition: left 300ms; }
.bad:hover { left: 20px; }

/* compositor only */
.good { transition: translate 300ms; }
.good:hover { translate: 20px 0; }
PropertyCostVerdict
transformCompositor onlyAnimate freely.
opacityCompositor onlyAnimate freely.
filterPaint, often on the GPUUsually fine.
color, background-colorPaintFine for small areas.
box-shadowPaint, whole boxAnimate a pseudo-element's opacity instead.
width, height, margin, padding, top, leftLayout, every frameAvoid. 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.
what the curve says
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.

a panel that fades both ways
.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.

respect the system setting
@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. Animate grid-template-rows: 0fr → 1fr, or max-height, or use calc-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 transform instead.

Practice