Transitions
Prerequisites: Transforms
A transition animates a property smoothly when its value changes — on hover, focus, or when JavaScript toggles a class.
.button {
background: #0b57d0;
transition: background-color 150ms ease-out, transform 150ms ease-out;
}
.button:hover {
background: #083b8a;
transform: translateY(-1px);
}
The shorthand is transition: property duration timing-function delay.
List several transitions separated by commas.
- Property — which property to animate.
allis convenient but can animate things you didn't intend; name them. - Duration — 100–300ms feels responsive for interface feedback.
- Timing function —
ease(default),linear,ease-in,ease-out(good for things entering),ease-in-out,cubic-bezier(...),steps(n), orlinear(...)for custom curves such as springs. - Delay — wait before starting.
What can transition
Anything with values in between: colors, lengths, opacity, transforms,
shadows. Discrete properties like display can't interpolate — they
flip at once. Modern browsers let you transition them with
transition-behavior: allow-discrete, used with @starting-style for
entry animations:
.toast {
transition: opacity 200ms, translate 200ms, display 200ms allow-discrete;
}
.toast[hidden] {
display: none;
opacity: 0;
translate: 0 1rem;
}
@starting-style {
.toast:not([hidden]) {
opacity: 0;
translate: 0 1rem;
}
}
@starting-style defines where the transition starts when an element
first appears (Chrome and Edge 117, Safari 17.5, Firefox 129).
Watching a transition from JavaScript
<style>
.box { width: 100px; transition: width 300ms linear; }
.box.wide { width: 300px; }
</style>
<div class="box"></div>
<script>
const box = document.querySelector(".box");
box.addEventListener("transitionend", e => {
console.log("transitionend:", e.propertyName, e.elapsedTime + "s", "width now", getComputedStyle(box).width);
});
getComputedStyle(box).width;
box.classList.add("wide");
console.log("running transitions:", box.getAnimations().length);
</script>
running transitions: 1
transitionend: width 0.3s width now 300px
Reading getComputedStyle(box).width before adding the class makes sure
the browser has a starting value; otherwise a change made in the same
frame the element is created may not animate.
Respect reduced motion
@media (prefers-reduced-motion: reduce) {
.button { transition-duration: 0.01ms; }
}
Color and opacity fades are generally fine; large movement, zooming,
and parallax are what cause problems. Many teams do the reverse — only
add motion inside @media (prefers-reduced-motion: no-preference).
Practice
Make .card lift 4px and gain a larger shadow on hover and on
:focus-within, over 200ms, only when the user hasn't asked for reduced
motion.
Show solution
.card {
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
}
.card:hover,
.card:focus-within {
box-shadow: 0 8px 20px rgb(0 0 0 / 16%);
}
@media (prefers-reduced-motion: no-preference) {
.card {
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover,
.card:focus-within {
transform: translateY(-4px);
}
}