CSS Transitions
Smooth, performant state changes — transition-property, timing functions, and the properties that animate cheaply vs expensively.
Making a State Change Happen Gradually Instead of Instantly
Without a transition, any CSS property change — a hover state flipping a background color, a class toggle changing an element's opacity — happens instantly, in a single frame. A CSS transition tells the browser to interpolate smoothly between the old value and the new value over a specified duration, instead of snapping directly to the end state.
.button {
background: #4285f4;
}
.button:hover {
background: #2f5fc4; /* snaps immediately on hover, no animation at all */
}.button {
background: #4285f4;
transition-property: background;
transition-duration: 200ms;
}
.button:hover {
background: #2f5fc4; /* now fades smoothly over 200ms instead of snapping */
}transition-property names which CSS property (or properties) should animate when they change. transition-duration sets how long that animation takes. Both are declared on the element's base state, not on the :hover (or other) state that triggers the change — the transition is a standing instruction that applies whenever the property's value changes for any reason, not just specifically on hover.
:hover, :focus, or :active pseudo-class, a class toggled by JavaScript, a media query boundary being crossed, or even a custom property (Module 30) being updated at runtime. It is not exclusively a hover effect, even though hover is the most common example used to teach it.Transitioning multiple properties at once
transition-property accepts a comma-separated list, or the keyword all, to watch every animatable property on the element at once.
/* Explicit — animates exactly these two properties */
.card {
transition-property: transform, box-shadow;
transition-duration: 200ms;
}
/* all — animates every property that changes, whatever it is */
.card {
transition-property: all;
transition-duration: 200ms;
}display or grid-template-columns change picked up incidentally by a later rule can produce a visually broken or unexpectedly slow transition. Listing properties explicitly is more verbose but keeps the animation's scope intentional and predictable.Easing — Controlling How the Animation Feels, Not Just How Long It Takes
transition-duration controls how long an animation takes; it says nothing about the rate it moves at during that time. transition-timing-function controls that rate — whether the motion starts slow and speeds up, starts fast and eases out, or moves at a perfectly constant speed throughout. This single property is responsible for a huge amount of how "polished" or "cheap" an interface feels, independent of everything else about it.
.el { transition-timing-function: linear; } /* constant speed, no easing at all */
.el { transition-timing-function: ease; } /* the default — slow start, fast middle, slow end */
.el { transition-timing-function: ease-in; } /* slow start, then accelerates — never eases out */
.el { transition-timing-function: ease-out; } /* starts fast, decelerates into the end state */
.el { transition-timing-function: ease-in-out; } /* slow start AND slow end, faster in the middle */linear almost always feels mechanical and slightly wrong for UI motion — real physical objects do not start and stop moving instantaneously, they accelerate and decelerate. ease-out is generally the most natural choice for anything entering or appearing on screen (it arrives with some initial speed and settles gently), while ease-in suits things leaving the screen (they build up speed as they exit).
cubic-bezier() — full manual control over the curve
Every keyword above is actually shorthand for a specific cubic-bezier() curve under the hood. Writing cubic-bezier() directly gives full control over the exact shape of the easing curve, defined by two control points.
.button {
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
/* A curve that slightly overshoots past 100% before settling back —
produces a subtle bounce-like snap, popular in modern UI micro-interactions. */
}cubic-bezier() curve from scratch. Browser DevTools (Chrome and Firefox both) include a visual curve editor directly in the Styles panel when you click a cubic-bezier() value, and easing-curve reference sites like easings.net provide named, pre-tuned curves for common feelings — "snappy," "gentle," "bouncy" — that you can copy directly into your CSS.steps() — discrete, non-smooth motion
A less common but genuinely useful timing function, steps(n) divides the transition into n discrete jumps instead of a continuous curve — used for effects like a sprite-sheet animation or a deliberately mechanical, "ticking" motion rather than a smooth glide.
.loading-dots {
transition: background-position 800ms steps(4);
/* Jumps through 4 distinct positions rather than sliding smoothly between them */
}Delaying the Start, and Writing All Four Properties at Once
transition-delay adds a pause before the transition begins, after the property change has already happened. It is used less often than duration or timing-function, but has real, specific uses — most commonly staggering multiple elements so they do not all animate in perfect, slightly artificial-looking unison.
.list-item {
opacity: 0;
transition: opacity 300ms ease-out;
}
.list-item.is-visible {
opacity: 1;
}
.list-item:nth-child(1) { transition-delay: 0ms; }
.list-item:nth-child(2) { transition-delay: 60ms; }
.list-item:nth-child(3) { transition-delay: 120ms; }
.list-item:nth-child(4) { transition-delay: 180ms; }
/* Each item fades in slightly after the one before it — a common
"cascading reveal" pattern for lists and cards entering the viewport. */The transition shorthand
In real, everyday CSS, all four longhand properties are almost always combined into the single transition shorthand, in a fixed order: property, duration, timing-function, delay.
/* Longhand — four separate declarations */
.card {
transition-property: transform, box-shadow;
transition-duration: 200ms;
transition-timing-function: ease-out;
transition-delay: 0ms;
}
/* Shorthand — one line, same result */
.card {
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}Notice the shorthand needs the full property duration timing-function group repeated per property when they need different durations or curves — there is no way to "share" a single duration across a comma-separated shorthand list where each property genuinely needs its own timing. If every property shares the same duration and timing function, listing them together under transition-property is cleaner, as shown in Part 01.
.dropdown {
transition: opacity 150ms ease-out, transform 250ms cubic-bezier(0.34, 1.56, 0.64, 1);
/* Opacity fades quickly and linearly-ish; the transform gets a longer,
slightly bouncy curve — two independently tuned animations on one element. */
}duration, and the second (if present) as delay. transition: transform 200ms 100ms ease-out; is genuinely valid and means a 200ms duration with a 100ms delay — but writing the numbers in the wrong intended order is a real, easy-to-make mistake that silently produces the wrong timing rather than an error.Layout, Paint, and Composite — The Three Stages a Property Change Can Trigger
Not every animatable CSS property costs the same amount of work for the browser to update on every single frame of a transition. To understand why, it helps to know the three broad stages a browser goes through whenever something on the page visually changes.
1. LAYOUT (also called "reflow")
Recalculates the geometry — size and position — of the changed element
AND potentially every element affected by that change (siblings, parents,
anything whose position depends on it).
2. PAINT
Fills in the actual pixels — colors, shadows, borders, text — for
every element whose visual appearance changed, onto one or more layers.
3. COMPOSITE
Combines all the already-painted layers together on the GPU and
displays the final result on screen.A property change can trigger all three stages, just paint and composite, or — the cheapest case — composite alone. The fewer stages a change requires, the less work the browser (and critically, the main thread, which is also busy running your JavaScript) has to redo on every single frame of an animation, typically 60 times per second for a smooth 60fps transition.
Properties that trigger layout — the expensive path
width, height
top, left, right, bottom (when positioned)
margin, padding
font-size
border-widthChanging any of these forces the browser to recompute the geometry of the changed element and potentially cascade that recalculation to surrounding elements too — a sibling that sits below an element growing in height has to be re-measured and repositioned on every single frame, not just once.
Properties that skip layout AND paint — the cheap path
transform (translate, scale, rotate, skew)
opacitytransform and opacity are the two properties modern browsers can animate using only the composite stage, entirely on the GPU, without touching layout or paint at all. The browser paints the element's pixels onto its own layer once, and every subsequent animation frame is purely a GPU operation — repositioning, scaling, or fading an already-painted layer — which is dramatically cheaper than repainting or re-laying-out the page on every frame.
transform and opacity instead of top/left or width/height, prefer the transform/opacity version — it is the single highest-leverage performance decision in everyday CSS animation work.A Concrete Comparison — Sliding a Card on Hover
The difference between the expensive and cheap paths is easiest to see by building the exact same visual effect twice — a card that lifts slightly and shifts upward when hovered — once using layout-triggering properties, and once using only transform.
.card {
position: relative;
top: 0;
transition: top 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover {
top: -6px;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
/* Every single frame of this 200ms transition forces a layout
recalculation, because "top" is a positioned offset that affects
the element's geometry — the browser has to re-measure and
potentially reposition surrounding content on every frame. */.card {
transform: translateY(0);
transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
/* translateY moves the element on the GPU compositor layer alone —
no layout recalculation on any frame. Visually pixel-identical
to the "top" version above. */Both produce the exact same on-screen motion — a 6px upward shift with a growing shadow. The difference is entirely in what the browser has to redo, 60 times a second, to render it. On a single card on a simple page, this difference may be invisible to the eye. On a real production page with dozens of animating cards, list items, or a heavy, complex DOM around the animating element, the layout-triggering version measurably drops frames and starts to visibly stutter, while the transform version keeps running smoothly, because it never leaves the GPU compositor at all.
opacity transitions instead — trading a small amount of markup complexity for a fully composite-only animation.How to check this yourself, in DevTools
Chrome DevTools' Performance panel, or the dedicated "Rendering" tab's "Paint flashing" and "Layer borders" overlays, make this difference directly visible — layout-triggered animations show up as repeated purple "Layout" blocks in a recorded performance trace, and repainted regions flash green under Paint Flashing on every frame. A transform-only animation shows almost none of either, confirming it is staying entirely on the compositor.
Transitions Are Not Only for :hover
Everything covered so far has used :hover as the trigger, since it is the simplest way to demonstrate a transition — but a transition fires from any change to the watched property's computed value, including one driven entirely by JavaScript toggling a class.
.modal {
opacity: 0;
transform: translateY(12px) scale(0.98);
transition: opacity 200ms ease-out, transform 200ms ease-out;
pointer-events: none;
}
.modal.is-open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}const modal = document.querySelector('.modal')
const openButton = document.querySelector('#open-modal')
openButton.addEventListener('click', () => {
modal.classList.add('is-open')
})The CSS has no idea a button was clicked — it only knows opacity and transform changed value, and animates that change exactly the way it would for a hover state. This separation — JavaScript only ever toggles a class or a data attribute, and CSS owns every detail of how that state change actually looks — is the standard, maintainable pattern for JavaScript-driven UI animation, keeping animation timing and easing entirely inside CSS rather than duplicated into JavaScript.
The transitionend event
JavaScript can listen for the exact moment a CSS transition finishes, which matters when something needs to happen only after the animation completes — removing an element from the DOM after it has fully faded out, for example, rather than yanking it away mid-animation.
const toast = document.querySelector('.toast')
function dismissToast() {
toast.classList.add('is-leaving') // triggers the CSS transition
toast.addEventListener('transitionend', () => {
toast.remove() // only runs once the fade-out has actually finished
}, { once: true })
}transitionend fires once per animated property, not once per element — if both opacity and transform are transitioning together, the handler can run twice for the same visual animation. Check event.propertyName inside the handler if you need to act on the completion of one specific property rather than the first one that happens to finish.A Janky Product Grid at a Chicago E-Commerce Company
A Chicago-based e-commerce company ships a product grid where each card scales up slightly on hover to draw attention. It looks smooth in isolated design review with three sample products, and ships. Once live, customer support starts receiving complaints of the grid feeling "laggy" and "stuttery" specifically on the category pages showing 40+ products at once — never on the pages with only a handful.
.product-card {
width: 240px;
transition: width 180ms ease-out, height 180ms ease-out;
}
.product-card:hover {
width: 252px;
height: calc(100% + 12px);
z-index: 2;
}What the front-end engineer finds using DevTools' Performance panel
Recording a hover interaction on the live category page shows a wall of purple "Layout" entries on every single frame of the 180ms transition — exactly the layout-triggering pattern from Part 04. Because width and height are geometry properties, every frame forces the browser to recompute the size of the hovered card and re-measure where every other card in the grid should sit relative to it, since a CSS grid or flex layout has to account for a sibling changing size. With 40+ cards on the page, that layout recalculation is genuinely expensive per frame — and directly explains why the effect felt fine with 3 sample cards but stutters visibly with a full real grid.
.product-card {
transition: transform 180ms ease-out, box-shadow 180ms ease-out;
}
.product-card:hover {
transform: scale(1.05);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
z-index: 2;
}
/* scale() achieves the same "grows slightly" effect purely on the
compositor — no width/height change, so no layout recalculation,
and no effect on any sibling card's position at all. */After the fix, the Performance panel recording for the same interaction shows no layout entries at all during the hover transition — only composite operations. The grid feels identically smooth whether it shows 3 products or 300, because the cost of the animation no longer scales with how many sibling elements are on the page at all. This exact diagnosis — an animation that "worked in the design mockup" but stutters specifically once real page density is involved — is one of the most common front-end performance investigations in production e-commerce and content-heavy interfaces.
Four Misconceptions About CSS Transitions
5 Interview Questions — With Complete Answers
Transition Mistakes Engineers Make Constantly
Rendering Bugs CSS Transitions Produce — And Exactly Why
🎯 Key Takeaways
- ✓transition-property, transition-duration, transition-timing-function, and transition-delay (or the transition shorthand) tell the browser to interpolate smoothly between an old and new value, instead of snapping instantly.
- ✓Declare the transition on the base selector, not the pseudo-class that triggers it, so it governs the change in both directions.
- ✓transition-timing-function controls the feel of the motion (acceleration/deceleration), independent of transition-duration, which only controls total time — ease-out generally suits things entering, ease-in suits things leaving.
- ✓A browser update goes through up to three stages: Layout, Paint, and Composite. transform and opacity can be animated on the composite stage alone, entirely on the GPU — the cheapest possible path.
- ✓width, height, top, left, margin, and padding all trigger a full layout recalculation on every animation frame, and that cost scales with how many surrounding elements are affected — a common cause of animations that stutter only on real, dense pages.
- ✓The same visual effect (a card growing, shifting, or lifting on hover) can almost always be re-expressed using transform: scale()/translate() instead of width/height/top/left, with an identical look and a fraction of the per-frame cost.
- ✓Transitions fire from any change to a watched property's value, including a class toggled by JavaScript — not only :hover — making CSS the source of truth for animation timing while JavaScript only toggles state.
- ✓transitionend fires once per transitioning property, not once per element — check event.propertyName when a handler must run for one specific property.
What comes next
Transitions only ever describe a path between two states. The next module covers @keyframes and the animation property — genuine multi-step, self-running, and looping motion, for effects a transition simply cannot express.
Next → CSS Animations & KeyframesDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.