Skip to main content

Harshal V. LADHE

An Interactive Guide to CSS Transitions

Craft high-performance, responsive UI motion with CSS transitions and transforms
Published at:
Last updated:
Estimated reading time:15 min read

Introduction

CSS transitions allow you to animate property changes smoothly over a duration — turning sudden visual changes into fluid, natural motion. Unlike keyframe animations, transitions are state-driven: the browser animates a property from one value to another automatically when that value changes.

They are perfect for UI enhancements like hover effects, color fades, button presses, and expanding menus.

What Are CSS Transitions?

A transition animates the change between two states of a property. When a property's value changes (like background-color, width, or opacity), the browser interpolates between old and new values over time.

.element {
  background-color: #007bff;
  transition: background-color 0.3s ease;
}

.element:hover {
  background-color: #0056b3;
}

Here, when you hover, the background color changes smoothly over 0.3s instead of instantly.

How Transitions Work

A transition only occurs when a property changes — usually due to:

  • Pseudo-classes (:hover, :focus, :active)
  • Class toggling via JavaScript
  • Inline style changes (e.g., element.style.height = "200px")
  • DOM manipulation or layout updates
const box = document.querySelector(".box");
box.addEventListener("click", () => {
  box.classList.toggle("slide-in");
});
.box {
  width: 100px;
  height: 100px;
  transition: transform 0.3s ease-in-out;
}

.box.slide-in {
  transform: translateX(-4px);
}

👉 Try this hands-on with the Toggle Transition State button in the interactive playground below — same class-toggle mechanism, live.

Transition Properties

CSS provides a set of transition-* properties to control transitions:

1. transition-property

The transition-property CSS property specifies which CSS properties should transition.

.element {
  transition-property: opacity;
}
  • You can list multiple properties separated by commas.
  • Use all to animate every animatable property (not recommended for performance reasons).
  • none disables the transitions.

2. transition-duration

The transition-duration CSS property defines how long the transition takes to complete. The duration can be specified either in seconds (s) or milliseconds (ms).

.element {
  transition-property: opacity;

  /* Duration */
  transition-duration: 0.3s;
}

👉 Try this hands-on with the Duration control in the interactive playground below.

You can specify multiple durations for multiple properties, maintaining the order defined in the transition-property property:

.element {
  transition-property: opacity, transform;

  /* Matches the order: opacity gets 0.3s, transform gets 0.6s */
  transition-duration: 0.3s, 0.6s;
}

3. transition-timing-function

The transition-timing-function CSS property controls the speed curve of the transition — essentially, how the intermediate states are calculated over time.

.element {
  transition-property: opacity;
  transition-duration: 0.3s;

  /* Timing function */
  transition-timing-function: ease-in-out;
}

Common Timing Functions:

FunctionDescription
linearConstant speed throughout.
easeStarts slow, speeds up, then slows down (default).
ease-inStarts slow, speeds up at end.
ease-outStarts fast, slows down at end.
ease-in-outSlow start and end, fast middle.
steps(n, start/end)Jump-based transitions for discrete steps.
cubic-bezier(x1, y1, x2, y2)Custom curve for precise control.

Here's what those curves actually look like, plotted on a shared timeline:

CSS transition timing function curvesA progress-versus-time plot comparing the linear, ease, ease-in, ease-out, and ease-in-out timing functions: linear is a straight diagonal, ease-in stays flat before rising steeply, ease-out rises steeply before flattening, and ease-in-out is a shallow S-curve that is slow at both ends.0%50%100%0s0.5s1sProgressTimelineareaseease-inease-outease-in-out

Analogy: Think of the transition as a car accelerating from a stoplight.

  • linear: The car instantly hits a constant speed and holds it. (Unnatural)
  • ease-in: The car starts slowly and then slams the gas at the end. (Hard start)
  • ease-out: The car accelerates quickly and then slowly coasts to its final stop. (Soft landing)
  • ease-in-out: The car accelerates smoothly and slows down smoothly, providing the most natural, polished feel.

👉 Try this hands-on in the Timing Function control of the interactive playground below — switch between the Preset, Steps, and Cubic-Bezier sub-groups to feel the difference.

You can specify multiple timing functions for multiple properties, maintaining the order defined in the transition-property property:

.element {
  transition-property: opacity, transform;
  transition-duration: 0.3s, 0.6s;

  /* Matches the order: opacity gets ease-out, transform gets ease-in-out */
  transition-timing-function: ease-out, ease-in-out;
}

4. transition-delay

The transition-delay CSS property defines how long to wait before the transition starts.

.element {
  transition-property: opacity;
  transition-duration: 0.3s;
  transition-timing-function: ease-in-out;

  /* Delay */
  transition-delay: 0.2s;
}

This is useful for staggering multiple transitions.

You can specify multiple delays for multiple properties, maintaining the order defined in the transition-property property:

.element {
  transition-property: opacity, transform;
  transition-duration: 0.3s, 0.6s;
  transition-timing-function: ease-out, ease-in-out;

  /* Matches the order: opacity gets 0s delay, transform gets 0.1s delay */
  transition-delay: 0s, 0.1s;
}

Negative Delays

A negative value for transition-delay is perfectly valid. It tells the browser to start the transition immediately, but from a state corresponding to a point partway through its defined duration.

How It Works:

The negative value essentially "rewinds" the transition's internal clock by that amount.

For example, applying transition-delay: -0.5s to a transition with a 1s duration means the transition starts instantly, but it visually skips the first half (0.5s) of its defined movement. The element appears at the midpoint of the transition's change, and the remaining 0.5s of the transition will play out.

Key Use Cases:

  • Pre-Positioning — The main power of a negative delay in transitions is pre-positioning elements at an intermediate state. Transitions, unlike animations, only define a start and end state (e.g., normal state and :hover state).
  • Pre-Setting the State — This is incredibly useful if you want an element to visually appear as if it is already partway through its transition when an event is triggered (like a hover or a class change).
ScenarioCodeResult
Normal State.element { transition: opacity 1s; transition-delay: 0s; }Transition starts at 0% opacity and fades in over 1 second.
Pre-Positioned State.element { transition: opacity 1s; transition-delay: -0.5s; }Transition starts immediately, but visually at 50% opacity (the halfway point), and completes the remaining fade over 0.5 seconds.

This technique eliminates the need to define a separate intermediate CSS rule just to position an element slightly advanced in its transition when the page loads or a state change occurs.

👉 Try this hands-on in the Delay control of the interactive playground below — it accepts negative values too, so you can feel the "pre-positioned" effect directly.

5. transition

Instead of writing multiple CSS transition properties separately, you can combine them into the transition shorthand property.

transition: <property> <duration> <timing-function> <delay>;

Example:

.element {
  /* Transition the 'opacity' property over 0.3s, using the ease-out curve, starting after a 0.1s delay. */
  transition: opacity 0.3s ease-out 0.1s;
}

You can also specify multiple transitions:

.element {
  transition: opacity 0.3s ease-out 0.1s, transform 0.6s ease-in-out 0s;
}

The Critical Order Rule: Duration vs. Delay

The order of the four components is flexible, except for the two time values (duration and delay):

  1. The first time value the browser reads is always the transition-duration.
  2. The second time value is always the transition-delay.

This means if you provide only one time value, the browser assumes it is the duration and the delay defaults to 0s.

ShorthandInterpretation
transition: opacity 1s ease;duration: 1s, delay: 0s
transition: opacity 0.5s linear 1s;duration: 0.5s, delay: 1s

Missing values (<property>, <timing-function>) will default to their initial states (all and ease, respectively).

Transition Events

You can listen to events triggered during or after transitions:

EventDescription
transitionrunFired right before transition starts.
transitionstartFired when a transition begins.
transitionendFired after transition completes.
transitioncancelFired if a transition is interrupted.

Example:

box.addEventListener("transitionend", () => {
  console.log("Transition completed!");
});

Debugging Transitions

If transitions feel janky:

  • Check if layout properties (width, height) are being animated.
  • Use DevTools → Performance → "Paint" view to detect repaints.
  • Replace layout-affecting transitions with transform: scale() or translate().

Why GPU-Accelerated Properties Are Cheap

Every style change forces the browser to redo some amount of rendering work, and that work happens in stages. Where a property lands in that pipeline decides how expensive changing it is:

StageTriggered ByWhat Happens
Layout (reflow)width, height, top, left, margin, font-sizeThe browser recalculates the geometry of the element — and often its siblings and ancestors too.
Paintcolor, background, box-shadow, border-radiusThe browser redraws pixels for the affected region into a bitmap layer.
Compositetransform, opacityThe GPU recombines already-painted layers — sliding, scaling, or fading them — with no Layout or Paint step at all.

transform and opacity are cheap specifically because they don't change the box model and don't require anything to be redrawn: the browser paints the element once, hands that layer to the GPU, and just moves or fades it on every frame from then on. That's also why compositor-only transitions stay smooth even while JavaScript is busy elsewhere — they run independently of the main thread.

Layout-affecting properties, by contrast, force the browser back through Layout and Paint on every frame of the transition, which is why animating width or top tends to look janky, especially on lower-powered devices — the main thread can't always keep up at 60fps.

The will-change property

Modern browsers optimize animations using the will-change property. It hints to the browser which properties are likely to change, allowing pre-optimization like promoting elements to their own layer, reducing lag for expensive transitions.

.card {
  will-change: transform, opacity;
}

Use sparingly — excessive will-change declarations force the browser to immediately allocate memory and GPU resources (layer creation), which can increase memory usage and reduce performance across the rest of the page. Only apply will-change just before the transition is about to occur and remove it immediately after, if possible, to avoid continuous memory use.

Common Pitfalls

Pitfall 1: The Transition Doesn't Play (JavaScript-Triggered)

A common bug: you insert an element and immediately add the class meant to trigger its transition — but nothing animates, it just snaps straight to the final state.

const el = document.createElement("div");
el.classList.add("fade"); // opacity: 0, transition: opacity 0.3s
container.appendChild(el);

el.classList.add("fade-in"); // ❌ opacity: 1 — but the transition doesn't play

This happens because the browser batches style changes made within the same synchronous block of JavaScript. It never "sees" the element in its initial opacity: 0 state before jumping to opacity: 1 — there's nothing to transition from.

The fix is to force the browser to compute and register that first style before changing it again. Reading a layout property like offsetHeight forces exactly that:

container.appendChild(el);

// Reading a layout property forces the browser to flush pending style
// changes and compute the current one before we change anything else.
el.offsetHeight;

el.classList.add("fade-in"); // ✅ now transitions correctly

A requestAnimationFrame (sometimes nested twice, for cross-browser safety) achieves the same thing by deferring the class change to a later frame instead of forcing a synchronous reflow:

container.appendChild(el);

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    el.classList.add("fade-in");
  });
});

Pitfall 2: You Can't Transition display or visibility (Discrete Properties)

Properties like display and visibility are discrete — they only have distinct states with nothing in between, so there's no gradual value to interpolate through. The browser has historically handled this by flipping the value instantly rather than gradually:

  • visibility flips to hidden only at the end of the transition (so the element stays visible and interactive throughout a fade-out), and flips to visible at the start when fading in.
  • display: none couldn't be transitioned at all — an element with display: none isn't rendered, so there's no box to animate from or to.

That's why the classic fade-out pattern combines both, using a matching delay so visibility only flips once opacity finishes:

.dropdown {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s, visibility 0s 0.3s;
}

.dropdown.open {
  opacity: 1;
  visibility: visible;
  transition-delay: 0s;
}

Modern CSS removes the need for this workaround. transition-behavior: allow-discrete lets discrete properties — including display — join the transition, and @starting-style gives the browser an explicit "before" state to animate from, which discrete properties otherwise don't have:

.modal {
  display: none;
  opacity: 0;
  transform: scale(0.95);
  transition: opacity 0.25s ease, transform 0.25s ease, display 0.25s allow-discrete;
}

.modal[open] {
  display: block;
  opacity: 1;
  transform: scale(1);
}

/* The state the browser transitions FROM when [open] is first applied */
@starting-style {
  .modal[open] {
    opacity: 0;
    transform: scale(0.95);
  }
}

Accessibility: Respecting Motion Preferences

Large or fast motion can cause real discomfort — dizziness, nausea, even migraines — for users with vestibular disorders. CSS gives you a way to detect that preference and scale back:

.card {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
}

Don't reach for transition: none everywhere by reflex, though. If a transition is communicating meaning — a loading spinner, a drag-and-drop reorder — removing it entirely can make the interface feel broken rather than calmer. In those cases, prefer shortening the duration or swapping transform-based motion for a simple opacity fade over cutting feedback out completely.

For transitions triggered from JavaScript, check the same preference before deciding to animate at all:

const prefersReducedMotion = window.matchMedia(
  "(prefers-reduced-motion: reduce)"
).matches;

if (!prefersReducedMotion) {
  box.classList.add("slide-in");
} else {
  box.classList.add("slide-in-instant"); // jumps to final state, no transition
}

Interactive Example

Reading about timing functions is one thing, but feeling them is another. Use this interactive playground to experiment with different easing curves and durations. Notice how a slight change in the cubic-bezier coordinates can turn a robotic movement into a snappy, organic interaction.

CSS Transitions Playground

Tune duration, delay, and timing function (preset, cubic-bezier, or steps) live against a transform + background-color transition.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>CSS Transitions Playground</title>
  </head>
  <body>
    <main class="playground">
      <section class="demo-area" aria-label="Transition preview">
        <div class="demo-track">
          <div id="animated-box" class="box-state-one"></div>
        </div>
      </section>

      <output id="transition-output" class="readout" aria-live="polite"></output>

      <fieldset class="control-group">
        <legend>Transition Properties</legend>
        <div class="fields-grid">
          <div class="field">
            <label for="duration-input">Duration</label>
            <div class="field-input">
              <input type="number" id="duration-input" min="0.1" max="10" step="0.1" value="3">
              <select id="duration-unit" class="unit-select" aria-label="Duration unit">
                <option value="s">s</option>
                <option value="ms">ms</option>
              </select>
            </div>
          </div>

          <div class="field">
            <label for="delay-input">Delay</label>
            <div class="field-input">
              <input type="number" id="delay-input" min="-10" max="10" step="0.1" value="0">
              <select id="delay-unit" class="unit-select" aria-label="Delay unit">
                <option value="s">s</option>
                <option value="ms">ms</option>
              </select>
            </div>
          </div>
        </div>
      </fieldset>

      <fieldset class="control-group">
        <legend>Timing Function</legend>

        <div class="segmented-control" role="radiogroup" aria-label="Timing function type">
          <label class="segment">
            <input type="radio" name="timing-type" value="preset" checked>
            <span>Preset</span>
          </label>
          <label class="segment">
            <input type="radio" name="timing-type" value="bezier">
            <span>Cubic-Bezier</span>
          </label>
          <label class="segment">
            <input type="radio" name="timing-type" value="steps">
            <span>Steps</span>
          </label>
        </div>

        <div id="timing-options-container">
          <div id="preset-options" class="timing-group active-group">
            <div class="field">
              <label for="timing-select-preset">Select Preset</label>
              <select id="timing-select-preset">
                <option value="ease">ease</option>
                <option value="linear">linear</option>
                <option value="ease-in">ease-in</option>
                <option value="ease-out">ease-out</option>
                <option value="ease-in-out">ease-in-out</option>
              </select>
            </div>
          </div>

          <div id="bezier-options" class="timing-group">
            <div class="fields-grid">
              <div class="field">
                <label for="bezier-x1">x1</label>
                <input type="number" id="bezier-x1" min="0" max="1" step="0.1" value="0.68">
              </div>

              <div class="field">
                <label for="bezier-y1">y1</label>
                <input type="number" id="bezier-y1" min="-2" max="2" step="0.1" value="-0.55">
              </div>

              <div class="field">
                <label for="bezier-x2">x2</label>
                <input type="number" id="bezier-x2" min="0" max="1" step="0.1" value="0.27">
              </div>

              <div class="field">
                <label for="bezier-y2">y2</label>
                <input type="number" id="bezier-y2" min="-2" max="2" step="0.1" value="1.55">
              </div>
            </div>

            <p class="tip">Try (0.1, 0.7, 1, 0.1) for a fast start.</p>
          </div>

          <div id="steps-options" class="timing-group">
            <div class="fields-grid">
              <div class="field">
                <label for="steps-count">Steps Count</label>
                <input type="number" id="steps-count" min="1" max="10" step="1" value="5">
              </div>

              <div class="field">
                <label for="steps-direction">Direction</label>
                <select id="steps-direction">
                  <option value="jump-end">jump-end (default)</option>
                  <option value="jump-start">jump-start</option>
                  <option value="jump-none">jump-none</option>
                  <option value="jump-both">jump-both</option>
                  <option value="start">start (classic)</option>
                  <option value="end">end (classic)</option>
                </select>
              </div>
            </div>

            <p class="tip">Use "start" to display first step immediately.</p>
          </div>
        </div>
      </fieldset>

      <button id="toggle-state-btn" class="toggle-btn" type="button">Toggle Transition State</button>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Ln , Col HTML4.9 KBUTF-8

Starting sandbox…

No console output yet.

No original version of /index.html to compare against.

Best Practices

Optimal Duration is Key: Aim for short durations, typically between 150–500ms, for user interface (UI) interactions. Transitions faster than 150ms can be missed, and those slower than 500ms can feel sluggish. 🐢

Prioritize Performance Properties (GPU-Accelerated): Avoid animating layout-affecting properties like width, height, top, or left. These trigger costly layout re-calculations (reflows). Instead, animate opacity or transform (e.g., scale, translate, rotate), as they run directly on the GPU (Graphics Processing Unit) and are significantly more performant.

Define Transitions in the Base State: Always declare the transition properties in the base selector (e.g., .element), not in the state selectors (:hover, .active). This ensures the transition applies both when entering the new state and when returning to the original state.

Leverage Shorthand and transform: Use the transition shorthand property for brevity and clarity. Always combine transitions with transform properties to create smooth, efficient motion that avoids taxing the main CPU thread.

Control the Exit: Use transition: none; (or explicitly set transition-duration: 0s;) when you need an instant visual update (e.g., hiding a pop-up or for accessibility toggles). This prevents unwanted animation on specific state changes.

Use will-change Judiciously: The will-change property should be used sparingly and briefly. Overuse forces the browser to prematurely allocate memory and GPU resources for every element, which can lead to increased memory usage and overall performance degradation across the page.

Respect Motion Preferences: Wrap non-essential motion in @media (prefers-reduced-motion: reduce) so users with vestibular disorders aren't forced to sit through it. This isn't just a nice-to-have — accessible motion is part of a well-built transition, not an afterthought.

Conclusion

CSS transitions are a lightweight, intuitive way to animate state changes. By using them correctly, particularly by leveraging GPU-accelerated properties like transform and opacity, you can ensure your UI motion is smooth, responsive, and performant. Understanding all transition-* properties, the nuances of timing functions, and strategically using will-change provides the control you need. Master them, and you'll build a strong foundation for crafting delightful micro-interactions and tackling more advanced CSS animations.

While transitions are excellent for simple, state-driven changes, complex motion that requires more than two steps, looping, or scroll-linking necessitates CSS Keyframe Animations. For a full feature comparison, see the Keyframes vs. Transitions Comparison Table in my An Interactive Guide to CSS Keyframes post.

Key Takeaways:

  • Use them for micro-interactions (hover, focus, click)
  • Combine with transform and opacity for best performance — they skip Layout and Paint entirely
  • Optimize with will-change only when necessary
  • Force a reflow (or use requestAnimationFrame) before adding a class that should trigger a transition on a freshly-inserted element
  • Reach for transition-behavior: allow-discrete and @starting-style instead of visibility hacks when animating display
  • Respect prefers-reduced-motion — accessible motion isn't optional
  • Keep motion subtle and consistent

This one took a fair bit of tinkering to get the playground feeling just right, and I hope tuning duration, delay, and timing-function with your own hands makes transitions click in a way reading alone never quite does. Thanks for hovering, toggling, and sticking with me through it — go make your UI feel a little smoother. ✨