Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. CSS transitions: smooth state changes in four properties
Web Development

CSS transitions: smooth state changes in four properties

Transitioning height to auto does not animate. Measured against an identical box going to a pixel value, one moved and one jumped.

By Max Arthur
Co-Founder & Content Marketer·September 14, 2026·11 Min read
White text reading 'CSS transitions explained' on a dark purple dotted background

A transition animates a property between two values whenever that property changes:

.button {
  background: #7c4dff;
  transition: background 200ms ease;
}
.button:hover {
  background: #4a21b0;
}

No keyframes, no JavaScript. You describe the two states and name what should animate between them.

The shorthand packs four things in a fixed order:

transition: <property> <duration> <timing-function> <delay>;
transition: width 300ms ease-in-out 100ms;

Measured, that resolves to exactly what you would expect:

transition-property: width
transition-duration: 0.3s
transition-timing-function: ease-in-out
transition-delay: 0.1s

The two time values are read in order: the first is the duration, the second is the delay. That is the only ambiguity in the shorthand, and swapping them produces an animation that appears not to start.

Not everything can be transitioned

Only properties with a defined intermediate value can animate. A colour has one, a length has one, display: block does not sit halfway to display: none.

The one that catches everyone is height: auto. Two identical boxes, both starting at height: 0:

#h   { height: 0; transition: height 400ms linear; }
#hpx { height: 0; transition: height 400ms linear; }

One is set to auto and the other to 60px. Measured 180ms into a 400ms transition:

Target Height mid-transition Height after
height: 60px 27.5px 60px
height: auto 18px 18px

The pixel version was caught partway, animating. The auto version was already at its final height at 180ms, having jumped there instantly.

auto is not a length, so there is nothing to interpolate towards. The browser applies it immediately and the transition is ignored.

Animating to auto anyway

Three approaches, in increasing order of how modern they are.

max-height with a guess. The traditional workaround:

.panel { max-height: 0; overflow: hidden; transition: max-height 300ms; }
.panel.open { max-height: 500px; }

It works, and the timing is wrong. If the content is 100px tall, the transition spends most of its duration animating through empty space between 100 and 500, so the panel appears to open fast and then pause. Guessing too low clips the content instead.

grid-template-rows, which does animate:

.wrapper { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 300ms; }
.wrapper.open { grid-template-rows: 1fr; }
.wrapper > * { overflow: hidden; }

fr units interpolate, so the row genuinely animates from nothing to exactly the content's height. The inner overflow: hidden is required, otherwise the content spills out of the collapsing row.

calc-size() and interpolate-size, the real fix, now arriving in browsers:

:root { interpolate-size: allow-keywords; }
.panel { height: 0; transition: height 300ms; }
.panel.open { height: auto; }

One line at the root makes auto animatable everywhere. Support is not universal yet, so the grid technique remains the reliable choice, and it is worth knowing that this problem is on its way out.

Transitioning display, and the discrete properties

Measured, transition-behavior defaults to normal, which means discrete properties such as display and visibility flip at the halfway point rather than animating.

That produces the classic fade-out bug: fade the opacity and set display: none, and the element vanishes immediately instead of fading, because display changed at once.

.toast {
  opacity: 1;
  transition: opacity 300ms, display 300ms allow-discrete;
}
.toast.hidden {
  opacity: 0;
  display: none;
}

allow-discrete delays the discrete change until the end of the transition, so the fade completes and then the element is removed. For an element appearing rather than disappearing, @starting-style supplies the value to animate from, since an element being added to the page has no previous state:

@starting-style {
  .toast { opacity: 0; }
}

Together those two features remove most of the reason to reach for JavaScript for enter and exit animations.

Which properties are cheap

Not all animatable properties cost the same. Two are cheap because the browser can hand them to the compositor without recalculating layout:

  • transform
  • opacity

Everything else is more expensive, and the properties that change layout are the worst, because changing one element's width or top can force the browser to recompute the position of everything after it, on every frame.

So the practical rule: animate transform and opacity wherever you can.

.card { transition: transform 200ms ease; }
.card:hover { transform: translateY(-4px); }   /* good */
.card { transition: top 200ms ease; }
.card:hover { top: -4px; }                     /* same look, more work */

Both produce a hover lift. The first is composited; the second triggers layout on every frame.

Worth remembering that transform on an element creates a stacking context and a containing block for fixed and sticky descendants, which is the cause of several bugs covered in why sticky is not working.

Timing functions

Measured, the default resolves to ease, not linear. Four keywords cover most needs:

Keyword Feels like
linear Constant speed. Mechanical, right for progress bars
ease Slow start, fast middle, slow end. The default
ease-out Fast start, gentle stop. Best for things entering
ease-in Gentle start, fast finish. Best for things leaving

ease-out is the one to reach for by default in interfaces. A menu that opens quickly and settles feels responsive; the same menu with ease-in feels sluggish because nothing happens for the first hundred milliseconds.

For anything custom, cubic-bezier() takes four numbers, and linear() now allows multi-point curves for bounce and spring effects without keyframes.

On duration: 150 to 300ms covers almost every interface transition. Under 100ms is not perceived as motion, and over 500ms feels slow when you are clicking through something repeatedly.

Transitions or animations

They overlap, and the choice is usually clear once framed properly.

A transition needs a trigger and two states. Something changes, and the browser animates between the old value and the new one. It runs once per change, and it cannot loop.

An animation with @keyframes runs on its own. It needs no state change, can loop forever, and can define as many intermediate steps as you like.

/* transition: reacts to a change */
.button { transition: transform 150ms ease-out; }
.button:active { transform: scale(0.97); }

/* animation: runs by itself */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 1s linear infinite; }

The rule of thumb: if a user action causes it, use a transition. If it happens regardless, use an animation. Hover states, open and close, focus rings and colour changes are transitions. Spinners, skeleton shimmers and attention loops are animations.

Transitions are also simpler to reason about, because there is exactly one path between two values. Reach for keyframes only when you need more than a start and an end.

Staggering several properties

The shorthand takes a comma-separated list, and each entry can have its own timing:

.card {
  transition:
    transform 200ms ease-out,
    box-shadow 200ms ease-out,
    background 400ms linear 100ms;
}

Three properties, three durations, and the last one starts 100ms late. That delay is what produces a layered feel rather than everything moving at once.

For a list of items entering one after another, transitions alone cannot stagger, because they have no concept of an index. A custom property set inline solves it:

<li style="--i: 0">…</li>
<li style="--i: 1">…</li>
li { transition: opacity 300ms ease-out; transition-delay: calc(var(--i) * 60ms); }

Each item reads its own index and delays accordingly. This is one of the clearest uses of CSS variables outside theming.

When a transition does not run at all

Four causes, in the order worth checking.

The property is not animatable. display, visibility and height: auto are the usual ones. The section above covers the fixes.

The transition is declared on the changed state. It belongs on the base rule. Declared inside :hover, the element animates on the way in and snaps back on the way out, because once the pointer leaves there is no transition rule in effect.

The element was just added to the page. There is no previous value to animate from, so it appears at its final state. @starting-style is the modern answer; the older trick was forcing a reflow by reading offsetHeight before applying the class.

Both values are not the same type. Transitioning from width: auto to width: 200px, or from no background-image to one, gives the browser nothing to interpolate. Both ends have to be concrete values of the same kind.

Respecting reduced motion

Some people get motion sickness from interface animation, and the operating system has a setting for it:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}

Near-zero rather than none, so that any code listening for transitionend still fires and state changes still complete. This block belongs in every stylesheet, and it takes one paste.

Transitions people actually write

Four patterns cover most interface work.

A hover lift, composited and therefore cheap:

.card {
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover {
  transform: translateY(-4px);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}

A button press, deliberately faster than the hover, because the feedback should feel immediate:

.button { transition: transform 80ms ease-out; }
.button:active { transform: scale(0.97); }

A colour change on focus, using :focus-visible so it appears for keyboard users without adding a ring on every mouse click:

.input { transition: border-color 150ms ease; }
.input:focus-visible { border-color: var(--brand); }

A disclosure panel, using the grid technique from above rather than max-height.

The pattern across all four: short durations, ease-out, and one or two properties named explicitly. Interface motion should be almost unnoticeable. If you can watch it happen, it is probably too slow.

Watching one that is not behaving

The Elements panel in DevTools shows an element's computed transition values, which confirms whether the rule applied at all. Two more techniques help when it did apply and still looks wrong.

Slow it down. Temporarily set the duration to 3s in DevTools and watch what actually moves. A transition that appears to jump often turns out to be animating a different property than you thought, or animating from an unexpected starting value.

Check for layout work. The Performance panel marks frames that triggered layout or paint. If a transition stutters, this is where you confirm whether it is animating something expensive, and the fix is usually to express the same movement as a transform.

transitionend also fires per property, not once per transition, so a listener on an element transitioning three properties runs three times. Check event.propertyName if you only care about one.

Three common mistakes

Transitioning height to auto. It jumps instead of animating. Use the grid technique, or interpolate-size where supported.

Putting the transition on the changed state. transition belongs on the base rule, not inside :hover. Declared only on :hover, the element animates in and snaps back out, because on leaving there is no longer a transition to obey.

Using transition: all. It animates properties you did not intend, including expensive ones, and it makes future changes to that rule unpredictable. Name the properties.

The View Transition API, briefly

Worth knowing exists, because it solves the thing transitions cannot: animating between two states of the page rather than two values of a property.

document.startViewTransition(() => {
  updateTheDOM();
});

The browser snapshots the page before and after, then cross-fades between them, animating elements that appear in both. Give matching elements the same view-transition-name and it will move them from one position to the other, even across a full page navigation.

That is genuinely new capability. A CSS transition needs the same element to persist with a changing property value; a view transition works when the element is removed and recreated, which is what happens on most framework re-renders and every page navigation.

For everyday hover states and toggles, ordinary transitions remain the right tool and always will be. Reach for view transitions when the thing that changed is the layout itself.

Quick reference

transition: opacity 200ms ease-out;
transition: transform 200ms ease-out, opacity 200ms ease-out;
transition: opacity 300ms, display 300ms allow-discrete;

@starting-style { .thing { opacity: 0; } }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; }
}

Want to build hover states and watch the frames? Start with the CSS track, or read Chrome DevTools for beginners for the panel that shows which properties triggered layout.

Keep reading

More from the blog

White text reading 'Why your z-index is not working' on a dark purple dotted background
September 17, 2026·7 min readWeb Development

z-index not working? It is the parent, not the number

A child with z-index 9999 loses to a sibling with z-index 1 when the parent has opacity. Same markup without it, and 9999 wins.

Read more
White text reading 'CSS variables explained' on a dark purple dotted background
September 16, 2026·10 min readWeb Development

CSS variables in practice: scope, fallbacks and theming

Custom properties inherit and cascade, which is what separates them from Sass variables. An invalid value falls back further than you expect.

Read more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentReact DeveloperPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs Mimo
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs Sololearn
Devpuff vs freeCodeCamp
Read All Comparisons