#Transitions
AddedA transition animates a property from its old value to its new value when that value changes. Something changes the computed style (a class is added, :hover starts matching, JavaScript sets a style), and instead of jumping, the browser interpolates over the duration you gave. You declare it on the element in its normal state: transition: <property> <duration> <timing-function> <delay>, with several comma-separated transitions if you need them.
Mental model: a transition is a rubber band between two computed values. It only exists while a change is happening, it needs both a "before" and an "after" value, and if the value changes again mid-way, the band is re-stretched from wherever it currently is. That's the difference from a keyframe animation, which runs on its own without any change.
Transitions are as old as CSS3 and Baseline everywhere. In a test (and in DevTools) a running transition is a real object: el.getAnimations() returns CSSTransition objects you can pause and seek, which is how every value below was measured.
.chip {
width: 100px;
height: 36px;
padding: 0 10px;
font: 13px/36px sans-serif;
background: #fecaca;
transition: width 1000ms linear, background-color 1000ms ease;
}
.chip.on,
.chip:hover {
width: 300px;
background: #bfdbfe;
}<div class="chip">Hover me</div>- At rest the chip is
100pxwide andrgb(254, 202, 202). Thetransitiondeclaration does nothing yet; it only says how future changes towidthandbackground-colorshould be animated. - Adding
.on(or hovering, in the live result) changes both computed values at once. Instead of jumping, the browser creates twoCSSTransitionobjects, one per property, each from the old value to the new one. - Paused at 500 ms of 1000 ms, the
linearwidth transition is exactly half way:200px. - The colour uses
ease, which starts fast and slows down, so at half the time it's already about 80% of the way:rgb(203, 216, 244), much closer to the target blue than to the starting pink. Colours are interpolated channel by channel. - When the transitions finish they're removed (
getAnimations()is empty again) and the element simply has its new computed values,300pxandrgb(191, 219, 254). Remove.onand two new transitions run back.
What each timing function does at 25% and 50%
The timing function maps elapsed time to progress. These are the widths of a 100 → 300 px transition, paused at 250 ms and 500 ms of 1000 ms.
.t { width: 100px; height: 8px; margin: 3px 0; background: #c7d2fe; }
.t.on { width: 300px; }
.ease { transition: width 1000ms ease; }
.ease-in { transition: width 1000ms ease-in; }
.ease-out { transition: width 1000ms ease-out; }
.ease-in-out { transition: width 1000ms ease-in-out; }
.steps { transition: width 1000ms steps(4); }
.overshoot { transition: width 1000ms cubic-bezier(0.3, 1.6, 0.6, 1); }
.custom { transition: width 1000ms linear(0, 0.9 50%, 1); }<div class="t ease"></div><div class="t ease-in"></div><div class="t ease-out"></div><div class="t ease-in-out"></div>
<div class="t steps"></div><div class="t overshoot"></div><div class="t custom"></div>ease(the default) is already at260.469pxat half time: it front-loads the movement.ease-outis similar (236.922px), and both feel responsive because the element reacts immediately.ease-inis the opposite, only163.062pxat half time: slow start, fast finish. It suits things leaving the screen and feels sluggish for things that should respond to a click.ease-in-outis symmetrical, so at exactly half time it's exactly half way,200px, but at 25% it's only125.828px.steps(4)jumps in four equal steps: at 25% of the time it has just taken its first jump to150px, at 50% its second to200px. Good for sprite-sheet animations and ticking clocks.cubic-bezier(0.3, 1.6, 0.6, 1)has a y value above 1, so it overshoots:321.609pxat half time, past the 300 px target, before settling back. That's how "springy" effects are made without JavaScript.linear(0, 0.9 50%, 1)(Baseline 2023) draws the curve point by point: progress 0.9 at 50% of the time. Measured280pxat 500 ms (100 + 0.9 × 200) and190pxat 250 ms, half way to that point. With enough points it can approximate bounces and springs.
Changing your mind half way
If the value changes back while a transition is running (the pointer leaves before the hover transition finished), the new transition starts from the current value, not from the end, and its duration is shortened in proportion. The spec calls this the reversing shortening factor.
.r { width: 100px; height: 20px; background: #a7f3d0; transition: width 1000ms linear; }
.r.on { width: 300px; }<div class="r"></div>- The forward transition is paused half way, at
200px. - Removing
.onsets a new target of100px. The browser cancels the forward transition and creates a new one whose keyframes go200px → 100px: it starts where the element is. - Its duration is
500ms, not 1000: it only has half the distance to cover. Without that rule, a quick hover-out would crawl back at half speed. - With a non-linear timing function the shortening uses the eased progress, so durations come out less round (with
easepaused at 300 ms, Chrome gave the way back 513 ms).
Changes that don't produce a transition
.auto { height: 0; overflow: hidden; background: #fde68a; transition: height 500ms; }
.auto.on { height: auto; }
.fade { opacity: 0.4; transition: opacity 1000ms; width: 120px; height: 20px; background: #bfdbfe; }<div class="auto"><p>Some content</p></div>height: 0 → autohas no transition (0animations); the box jumps to its content height.autoisn't a number the browser can interpolate. Workarounds: transitiongrid-template-rows: 0fr → 1fr, animatemax-heightto a guessed value, or useinterpolate-size: allow-keywords/calc-size(), which are Chrome-only as of October 2026.- A
.fadeelement inserted by JavaScript gets no transition for its first style, even though it has atransition: there was no "before" value to transition from. That's why an element appearing withdisplayor frominsertBeforedoesn't fade in. The fix is Entry animations with @starting-style and transition-behavior. - Once it exists, a later change (
opacityto1) transitions normally. - Discrete properties such as
displaydon't interpolate either; by default they just flip.transition-behavior: allow-discrete(same topic) lets them take part.