Smoothing a Change That Was Going to Happen Anyway
A transition does not create movement of its own. It takes a change that CSS was already going to make instantly — a colour switching on hover, a panel appearing on focus, a class added by JavaScript — and spreads it over a period of time so the eye can follow it. Without a transition the change happens in a single frame; with one, the browser calculates the in-between values for you.
The most important practical detail is where you declare it. The transition belongs on the element's normal state, not inside the :hover rule. The browser uses whichever transition is in effect at the moment the change begins, so a transition written only inside :hover applies while the mouse arrives and is gone when the mouse leaves — the element eases in and then snaps back. Put it on the base rule and both directions are smooth. When you want a deliberately asymmetric effect, such as a fast hover and a slow return, that is exactly the knob to use.
Because a transition only responds to a change, something has to cause one. In pure CSS that means a state change: :hover, :focus, :focus-visible, :checked, or a class being added or removed by a script. An element with a transition and nothing changing it simply sits there.
/* Wrong: eases in, snaps back */
.button:hover {
background: #7a0060;
transition: background 0.25s ease;
}
/* Right: the transition lives on the base state */
.button {
background: #d1039e;
transition: background 0.25s ease;
}
.button:hover {
background: #7a0060;
}
/* Deliberately asymmetric: quick in, slow out */
.card { transition: box-shadow 0.4s ease; }
.card:hover { transition: box-shadow 0.15s ease; box-shadow: 0 8px 24px rgba(0,0,0,0.12); } - Transitions also fire on
:focus-visible, which is the state a keyboard user reaches by tabbing. If you build a hover effect on an interactive element, add the same treatment for focus — otherwise the interface feels alive with a mouse and completely dead from the keyboard.
The Four Parts, and Getting the Timing Right
A transition has four settings. transition-property names what to animate. transition-duration is how long it takes. transition-timing-function describes how the speed varies over that time. transition-delay waits before starting. The shorthand takes them in that order, and there is one rule to remember: if you write two time values, the first is the duration and the second is the delay. Swapping them by accident gives you an effect that appears to do nothing for half a second.
Duration is a design decision more than a technical one. Somewhere between 150 and 300 milliseconds covers almost every interface change — long enough to be seen, short enough not to feel like waiting. Below about 100ms the movement is barely perceptible; above 500ms an interface starts to feel sluggish, because the user has already decided what they want and is now watching an animation finish.
The timing function shapes the motion. linear moves at a constant rate and looks mechanical, which is right for a loading spinner and wrong for almost everything else. ease-out starts fast and settles gently, which suits things arriving on screen — the response feels immediate. ease-in does the reverse and suits things leaving. ease is the default and is a reasonable general choice. cubic-bezier() lets you define your own curve when none of the keywords fit.
/* Longhand */
.button {
transition-property: background-color, transform;
transition-duration: 0.25s;
transition-timing-function: ease-out;
transition-delay: 0s;
}
/* Shorthand: property duration timing delay */
.button { transition: background-color 0.25s ease-out; }
/* Two times = duration then delay, in that order */
.tooltip { transition: opacity 0.2s ease 0.4s; } /* waits 0.4s, takes 0.2s */
/* Several properties, each with its own timing */
.card {
transition:
transform 0.2s ease-out,
box-shadow 0.3s ease-out,
border-color 0.15s linear;
}
/* A custom curve when the keywords are not enough */
.drawer { transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); } - 150–300ms suits most interface changes
ease-outfor things appearing,ease-infor things leavinglinearonly for continuous motion such as a spinner- In the shorthand, the first time is the duration and the second is the delay
- List several transitions separated by commas to give each property its own timing
- A delay on the way out is what keeps a dropdown open while the mouse travels to it
What Can Be Transitioned — and the height: auto Problem
A property can only be transitioned if the browser can calculate the values in between. Numbers, lengths, colours, shadows and transforms all interpolate cleanly. Keywords generally do not: there is no halfway point between display: none and display: block, so that change is instant no matter what you write. This is why fading an element out is done with opacity, with visibility switching at the end, rather than with display.
The most common casualty is height: auto. Expanding an accordion panel from zero to its natural height feels like the most basic animation there is, and it does not work — auto is not a number, so there is nothing to interpolate towards and the panel just appears. Two workarounds are in wide use. The old one is max-height: transition from 0 to a value comfortably larger than the content will ever be. It works everywhere, but the timing is slightly off, because the browser animates towards your invented maximum rather than the real height, so a short panel finishes early and appears to pause.
The newer approach uses a grid container whose single row is transitioned from 0fr to 1fr, with the panel inside set to overflow: hidden. Because the row is sized in fractions of the real content, the animation ends exactly when the panel is fully open, at whatever height that turns out to be. It needs one extra wrapper element, and it is worth it for anything where the content length varies.
Finally, avoid transition: all. It looks convenient and causes two problems: it animates properties you never intended, including ones a browser update might add later, and it forces the browser to watch every property for changes. Naming the two or three properties you actually mean is clearer to read and cheaper to run.
/* Does nothing — auto cannot be interpolated */
.panel { height: 0; transition: height 0.3s ease; }
.panel.is-open { height: auto; }
/* The classic workaround */
.panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.panel.is-open { max-height: 500px; } /* a guess, larger than the content */
/* The precise version: animate a grid row from 0fr to 1fr */
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.panel > .panel__inner { overflow: hidden; }
.panel.is-open { grid-template-rows: 1fr; }
/* Name your properties instead of using all */
.card { transition: transform 0.2s ease-out, box-shadow 0.2s ease-out; } - Anything can be transitioned only between two states that both exist. If your "from" state is missing — for instance an element that is added to the page already in its final state — there is nothing to animate from, and the transition never runs. Animations with
@keyframes, covered in the next lesson, do not have that limitation.
Smooth Motion, and Respecting People Who Do Not Want It
Not every property is equally cheap to animate. Changing width, height, top, left, margin or padding forces the browser to recalculate the position of everything around the element, on every single frame. On a phone that shows up as visible stuttering.
transform and opacity are the exceptions. The browser can handle both without redoing layout, which is why they stay smooth even on modest hardware. In practice this means: to move something, use transform: translate() rather than left. To resize it, use transform: scale() rather than width. To fade it, use opacity. Those three cover the great majority of interface motion, and the habit costs nothing to adopt.
There is an accessibility duty here too. For people with vestibular disorders, movement on screen can cause real nausea and dizziness — this is not a preference, it is a medical response. Operating systems have a "reduce motion" setting for exactly this reason, and CSS can read it through the prefers-reduced-motion media query. Wrapping a short rule at the end of your stylesheet that cuts durations to almost nothing respects that choice across your whole site in a few lines. Keep the state changes — the colour still changes, the panel still opens — and remove the movement.
The demo below puts the ideas together in one small set of components. Change the durations, swap ease-out for linear, and try moving the transition line into the :hover rule to feel the difference the placement makes.
/* Cheap to animate */
.card:hover { transform: translateY(-4px); }
.fade { opacity: 0; transition: opacity 0.2s ease; }
.fade.is-in { opacity: 1; }
/* Expensive — forces layout on every frame */
.card:hover { top: -4px; width: 320px; }
/* Respect the operating system setting */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
} HTML
<h3>Buttons</h3>
<button class="btn">Hover or tab to me</button>
<button class="btn btn--slow">Slower, with a delay</button>
<h3>Card lift</h3>
<div class="card">
<h4>Transform and shadow</h4>
<p>Moved with translateY, not with top — no layout work per frame.</p>
</div>
<h3>Accordion using grid-template-rows</h3>
<div class="acc">
<button class="acc__btn" onclick="this.parentElement.classList.toggle('is-open')">Toggle panel</button>
<div class="panel">
<div class="panel__inner">
<p>This panel animates from 0fr to 1fr, so it stops exactly at its own height however much text it contains.</p>
</div>
</div>
</div> CSS
body {
font-family: system-ui, Arial, sans-serif;
padding: 24px;
background: #f4f4f7;
}
h3 { font-size: 15px; color: #555555; margin: 24px 0 8px; }
/* The transition lives on the BASE state */
.btn {
background: #d1039e;
color: #ffffff;
border: none;
border-radius: 8px;
padding: 12px 22px;
font-size: 15px;
cursor: pointer;
transition: background-color 0.2s ease-out, transform 0.2s ease-out;
}
.btn:hover { background: #7a0060; transform: translateY(-2px); }
.btn:active { transform: translateY(0); }
.btn:focus-visible {
outline: 3px solid #7a0060;
outline-offset: 3px;
}
.btn--slow { transition: background-color 0.6s ease 0.2s; }
.card {
background: #ffffff;
border: 1px solid #e3e3ea;
border-radius: 12px;
padding: 20px;
max-width: 360px;
transition: transform 0.25s ease-out, box-shadow 0.25s ease-out;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
}
.card h4 { margin: 0 0 8px; color: #d1039e; }
.card p { margin: 0; color: #555555; font-size: 14px; }
.acc { max-width: 360px; }
.acc__btn {
width: 100%;
text-align: left;
background: #ffffff;
border: 1px solid #e3e3ea;
border-radius: 8px;
padding: 12px 16px;
font-size: 15px;
cursor: pointer;
}
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.acc.is-open .panel { grid-template-rows: 1fr; }
.panel__inner { overflow: hidden; }
.panel__inner p { margin: 12px 4px; color: #555555; font-size: 14px; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0.01ms !important;
}
} - Animate
transformandopacitywherever you can - Avoid animating
width,height,top,left,marginandpadding - Declare the transition on the base state, not inside
:hover - Give
:focus-visiblethe same treatment as:hover - Honour
prefers-reduced-motion— for some people motion is a health issue, not a taste - Avoid
transition: all; name the properties you mean
