CSS Transition Fundamentals: Architecture, Performance & Patterns

Mastering CSS Transition Fundamentals is critical for building responsive, performant interfaces. Unlike imperative JavaScript animations, declarative transitions bridge UI state changes with minimal overhead and native browser optimization. This guide covers spec-compliant syntax, component-scoped architecture, and GPU compositing strategies. We will explore how transitions integrate into broader CSS-Only Micro-Interactions & Animations systems, ensuring smooth UX without triggering costly layout recalculations.

Key Implementation Points:

  • Declarative vs imperative animation paradigms
  • Spec-compliant transition syntax & shorthand parsing
  • GPU compositing & transform isolation techniques
  • Component-scoped transition architecture & state management
Transition timeline anatomy A start state and end state connected by a duration, delay, and timing function along a time axis. Anatomy of a state transition t = 0 t = end start state end state transition-delay duration + easing

Prerequisites

This guide assumes you are comfortable with the following. If any of them are unfamiliar, they are worth a detour first — transitions misbehave in confusing ways when the underlying model is shaky.

  • Selectors and state pseudo-classes:hover, :focus-visible, :checked, :has(), and attribute selectors, since every transition is fired by one of them.
  • The difference between specified, computed, and used values. Transitions interpolate computed values, which is why width: auto and height: auto behave unlike width: 50%.
  • Which properties are animatable and what their interpolation type is (numeric, colour, transform list, or discrete).
  • The rendering pipeline — style, layout, paint, composite. You need this to reason about which properties are cheap.
  • Custom properties (--token) and calc(), used throughout for the token architecture below.
  • Basic DevTools fluency: inspecting computed styles and forcing element state.

Core concept: a transition is a comparison, not an instruction

The single most useful mental model is this: you never tell the browser to run a transition. You describe which properties are watchable, and the browser runs a comparison for you on every style recalculation.

The CSS Transitions specification calls each recalculation a style change event. At every one of those events, the browser holds two things for each element: the before-change style (the computed style the element had after the previous style change event, with any running transitions already applied) and the after-change style (the computed style it has now). For every property named in transition-property, it asks four questions:

  1. Do the before-change and after-change computed values differ?
  2. Are the two values interpolable — that is, is there a defined way to produce intermediate values between them?
  3. Is the combined duration and delay greater than zero?
  4. Does a before-change style exist at all?

If all four are true, a transition is started and driven by the compositor or the main thread until it completes. If any is false, the property snaps to its new value in one frame.

Four consequences fall straight out of that definition, and they explain nearly every transition bug worth debugging.

Consequence one: the declaration must live where both states can see it. transition is looked up on the after-change style, but it also has to be present when the element returns to rest. A transition declared inside :hover vanishes the moment the pointer leaves, so the element animates in and snaps out. Put the shorthand on the base selector.

Consequence two: an element's very first style computation has no before-change style, so nothing animates on insertion. That is by design — it is why a freshly appended element cannot fade in with a plain transition. The dedicated rule for supplying that missing before-change style is covered in @starting-style entry animations.

Consequence three: "interpolable" is a hard gate, not a preference. display: none to display: block has no midpoint, so by default the property is not transitioned at all. transition-behavior: allow-discrete changes the rule for discrete properties, flipping them at the halfway point instead of refusing; the allow-discrete guide works through the enter and exit timelines that result.

Consequence four: interrupting a transition does not restart it from scratch. When a property that is already transitioning gets a new target, the spec computes a reversing shortening factor from how far the current transition has progressed, and shortens the new one proportionally. This is why flicking the pointer on and off a button feels responsive rather than sluggish, and it is behaviour you get for free that a naive JavaScript implementation has to reimplement.


The Transition Property & Shorthand Syntax

The transition property is a shorthand for five longhand properties. Understanding the parsing order is essential for predictable state changes.

Syntax and parameters

TokenAccepted valuesInitial value
transition-propertynone, all, or a comma-separated list of animatable property namesall
transition-duration<time> (s or ms), non-negative, comma-separated list0s
transition-timing-functionlinear, ease, ease-in, ease-out, ease-in-out, step-start, step-end, steps(), cubic-bezier(), linear()ease
transition-delay<time>, negative allowed (starts mid-curve)0s
transition-behaviornormal, allow-discretenormal
transition (shorthand)<property> <duration> <timing-function> <delay> <behavior> per comma-separated layerresets all five

Two rules govern the shorthand that trip people up regularly. First, order matters only for the two time values: the first <time> encountered is always the duration and the second is always the delay, while property name, timing function, and behavior keyword can appear in any order because they are unambiguous token types. Second, the lists are matched by index and cycled. If transition-property names three properties but transition-duration supplies two values, the third property reuses the first duration. A longer duration list than property list is simply truncated.

/* ✅ Explicit, performant, and spec-compliant */
.card {
  transition:
    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
    opacity 0.2s ease-out;
}

.card:hover {
  transform: translateY(-4px);
  opacity: 0.95;
}

/* ❌ Avoid: Forces browser to monitor every animatable property */
.card {
  transition: all 0.3s ease;
}

The negative delay deserves a note because it is genuinely useful rather than a curiosity. transition-delay: -0.1s on a 0.3s transition starts the interpolation already one-third of the way along its curve. On a set of sibling elements you can use that to make a group finish together while starting from different visual offsets, without writing a second set of rules.

Spec Reference: CSS Transitions Module Level 1 (W3C) defines the exact parsing algorithm and fallback behavior for malformed shorthand declarations.


Step-by-step implementation

The following sequence builds one interactive card from an unstyled baseline to a production-grade transition. Each step is complete and runnable on its own; the point is to show which decision each layer represents.

Step 1 — Establish the state change with no motion at all

Motion is an enhancement on top of a state change that already works. Write the state change first and confirm it is correct before adding any timing.

.card {
  display: block;
  padding: 1.25rem;
  border: 1px solid #d4d8e0;
  border-radius: 12px;
  background: #ffffff;
  box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
}

.card:hover,
.card:focus-visible {
  border-color: #7aa2ff;
  box-shadow: 0 10px 24px rgb(15 23 42 / 0.14);
}

If the card is not clearly distinguishable in its hover state with the transition removed, no amount of easing will fix it. This step also guarantees a usable result in any environment where motion is suppressed.

Step 2 — Name the properties explicitly

Add the shorthand to the base rule, and list properties by name rather than using all.

.card {
  display: block;
  padding: 1.25rem;
  border: 1px solid #d4d8e0;
  border-radius: 12px;
  background: #ffffff;
  box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
  transition:
    border-color 0.2s ease-out,
    box-shadow 0.2s ease-out;
}

.card:hover,
.card:focus-visible {
  border-color: #7aa2ff;
  box-shadow: 0 10px 24px rgb(15 23 42 / 0.14);
}

Naming properties is not only a performance choice. It is a correctness choice: all will happily animate a property some future stylesheet change introduces, producing motion nobody asked for.

Step 3 — Move displacement onto the compositor

box-shadow is a paint-bound property. On a large card, animating it every frame means repainting the whole element. Move the lift itself to transform and let a pseudo-element carry the shadow so its opacity — not its blur geometry — is what changes.

.card {
  position: relative;
  display: block;
  padding: 1.25rem;
  border: 1px solid #d4d8e0;
  border-radius: 12px;
  background: #ffffff;
  transition:
    transform 0.2s ease-out,
    border-color 0.2s ease-out;
}

.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 10px 24px rgb(15 23 42 / 0.14);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease-out;
}

.card:hover,
.card:focus-visible {
  transform: translateY(-4px);
  border-color: #7aa2ff;
}

.card:hover::after,
.card:focus-visible::after {
  opacity: 1;
}

The shadow is now rendered once, at full strength, and only its alpha changes — a compositor operation. The performance and GPU acceleration guide covers when this trade is worth the extra element and when it is premature.

Step 4 — Tokenise duration and easing

Hard-coded durations scattered across a stylesheet drift apart. Lift them into custom properties on the component root so a modifier can retune the whole component by setting one value.

.card {
  --card-speed: 0.2s;
  --card-ease: cubic-bezier(0.2, 0.8, 0.2, 1);

  position: relative;
  display: block;
  padding: 1.25rem;
  border: 1px solid #d4d8e0;
  border-radius: 12px;
  background: #ffffff;
  transition:
    transform var(--card-speed) var(--card-ease),
    border-color var(--card-speed) var(--card-ease);
}

.card--prominent {
  --card-speed: 0.32s;
  --card-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
}

.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 10px 24px rgb(15 23 42 / 0.14);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--card-speed) var(--card-ease);
}

.card:hover,
.card:focus-visible {
  transform: translateY(-4px);
  border-color: #7aa2ff;
}

.card:hover::after,
.card:focus-visible::after {
  opacity: 1;
}

Because custom properties are inherited, --card-speed set on an ancestor cascades into every nested component that reads it — a property the section on custom-properties architecture turns into a full token system.

Step 5 — Make entry and exit asymmetric

Interfaces feel best when things arrive with a decelerating curve and leave faster on a linear-ish one. Because transition is read from the after-change style, the state rule controls the timing of the move into that state, and the base rule controls the move back out.

/* leaving: quick and unfussy — read from the base rule */
.card {
  transition:
    transform 0.14s ease-in,
    border-color 0.14s ease-in;
}

/* arriving: slower and decelerating — read from the hover rule */
.card:hover,
.card:focus-visible {
  transform: translateY(-4px);
  border-color: #7aa2ff;
  transition:
    transform 0.26s cubic-bezier(0.2, 0.8, 0.2, 1),
    border-color 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
}

This is the one legitimate reason to declare transition inside a state rule, and it only works because the base rule still declares its own. Curve selection for each direction is the subject of the transition timing functions guide.

Step 6 — Gate the motion, not the state change

Finally, make the durations conditional rather than the styles. Everything above still applies when motion is suppressed; only the timing disappears.

.card {
  --card-speed: 0s;
}

@media (prefers-reduced-motion: no-preference) {
  .card {
    --card-speed: 0.2s;
  }
}

Writing it in this direction — zero by default, motion added under no-preference — means a browser or environment that reports nothing at all still gets a working, motionless interface. The accessibility in CSS animations guide covers the full reasoning behind that default.


Annotated production example: a navigation item with an animated indicator

This is a realistic, copy-paste component that exercises every idea above: base-rule declaration, compositor-safe properties, asymmetric timing, token-driven durations, a keyboard-equivalent state, and a reduced-motion gate.

<nav class="nav" aria-label="Primary">
  <a class="nav__link" href="/transitions/" aria-current="page">Transitions</a>
  <a class="nav__link" href="/keyframes/">Keyframes</a>
  <a class="nav__link" href="/accessibility/">Accessibility</a>
</nav>
.nav {
  /* One place to retune every link in this navigation. */
  --nav-in: 0s;
  --nav-out: 0s;
  --nav-ease-in: cubic-bezier(0.2, 0.8, 0.2, 1);
  --nav-ease-out: ease-in;

  display: flex;
  gap: 0.5rem;
}

@media (prefers-reduced-motion: no-preference) {
  .nav {
    --nav-in: 0.26s;
    --nav-out: 0.14s;
  }
}

.nav__link {
  position: relative;
  padding: 0.6rem 0.9rem;
  color: #334155;
  text-decoration: none;
  border-radius: 8px;

  /* Declared on the BASE rule, so it also times the return to rest. */
  transition:
    color var(--nav-out) var(--nav-ease-out),
    background-color var(--nav-out) var(--nav-ease-out);
}

.nav__link::after {
  content: "";
  position: absolute;
  inset-inline: 0.9rem;
  bottom: 0.35rem;
  height: 2px;
  background: #2563eb;

  /* scaleX from 0 is a compositor transform: no layout, no repaint of the bar. */
  transform: scaleX(0);
  transform-origin: center;
  transition: transform var(--nav-out) var(--nav-ease-out);
}

/* :focus-visible sits alongside :hover so keyboard users get the same affordance.
   Using :focus-visible rather than :focus keeps the indicator off mouse clicks. */
.nav__link:hover,
.nav__link:focus-visible {
  color: #0f172a;
  background-color: rgb(37 99 235 / 0.08);

  /* Arriving is slower and decelerates; leaving uses the base rule above. */
  transition:
    color var(--nav-in) var(--nav-ease-in),
    background-color var(--nav-in) var(--nav-ease-in);
}

.nav__link:hover::after,
.nav__link:focus-visible::after {
  transform: scaleX(1);
  transition: transform var(--nav-in) var(--nav-ease-in);
}

/* The current page is a persistent state, not an interaction — no motion at all. */
.nav__link[aria-current="page"]::after {
  transform: scaleX(1);
  transition: none;
}

/* The focus ring itself must never be animated away or delayed. */
.nav__link:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

Three details are worth calling out. The indicator uses scaleX on a pseudo-element rather than animating width, so the browser never re-runs layout for the parent link. inset-inline rather than left/right means the underline inset flips correctly in right-to-left writing modes. And transition: none on the current-page indicator prevents a distracting sweep on every page load, because that indicator represents a fact about the document rather than a response to the user.


Component Architecture & State Management

Transitions should be scoped to interactive states without bleeding into global styles. Aligning with modern Hover & Focus State Design methodologies, we leverage CSS custom properties to create predictable, theme-aware transition tokens.

Dynamic Easing & Duration via Custom Properties

By defining transition parameters at the component root, you enable runtime overrides without duplicating transition logic across modifiers.

.btn {
  --transition-speed: 0.2s;
  --transition-ease: ease-out;
  transition:
    background-color var(--transition-speed) var(--transition-ease),
    transform var(--transition-speed) var(--transition-ease);
}

.btn--primary {
  --transition-speed: 0.3s;
  --transition-ease: cubic-bezier(0.34, 1.56, 0.64, 1);
}

.btn--loading {
  --transition-speed: 0.4s;
  --transition-ease: linear;
}

Avoiding Nested Conflicts

When components inherit transitions from parent containers, use transition: none on child elements that require instant state changes. This prevents unintended cascade behavior in deeply nested BEM or utility-class trees.


Performance & accessibility notes

Transitions only perform optimally when confined to properties handled by the browser's compositor thread. Animating layout-triggering properties (width, height, top, left, margin) forces synchronous repaint and reflow, causing jank.

Compositor-only properties

Stick to transform, opacity, and filter (with caution). These properties are isolated to the GPU compositor, bypassing the main thread entirely.

.interactive-panel {
  /* Force layer promotion to avoid first-frame layout recalculation */
  transform: translateZ(0);
  will-change: transform;
  transition: transform 0.25s ease;
}

.interactive-panel:hover {
  transform: translateZ(0) scale(1.02);
}

A rough cost ordering to keep in your head: transform and opacity are composite-only; filter and backdrop-filter are composite but expensive per pixel and can force a repaint of what sits behind them; color, background-color, box-shadow, border-color, and outline-color require paint; anything affecting box geometry requires layout for the element and its siblings. A transition on border-color across a dozen list items is fine. The same transition on padding is not.

Strategic will-change usage

will-change is a hint, not a guarantee. Overusing it consumes significant GPU memory and can reduce performance by fragmenting the layer tree. Apply it only when the element is about to transition — a common CSS-only approach is to declare it on the parent's :hover so the layer is promoted a frame before the child moves — and let it fall away afterwards. The will-change and compositor thread guide covers the memory arithmetic in detail.

Accessibility obligations specific to transitions

Three obligations apply to any transitioned state change:

  • The state change must survive without the motion. If your only signal that a button is focused is that it moved, a user with motion suppressed has no signal at all. Always pair displacement with a non-spatial cue: colour, outline, or opacity.
  • Never transition the focus indicator's visibility. A focus ring that fades in over 300ms is unusable for fast keyboard navigation. Transition its colour if you must; never its presence.
  • Do not delay a state change past the point of feedback. Interaction feedback should begin within roughly 100ms of the input, which means transition-delay on hover states is almost always a mistake — it reads as an unresponsive interface.
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
    animation-duration: 0.01ms !important;
  }
}

The 0.01ms value rather than 0s matters because it keeps transitionend events firing, so any script that waits on them does not hang.


Choosing between transitions and everything else

The rest of this area exists because transitions are only one of five ways to move something in CSS. Use this as a routing table.

You needUseWhy
A → B on hover, focus, :checked, or an attribute changetransitionThe browser already knows both endpoints; interruption handling is free.
A looping or multi-step sequence, or motion that runs without user input@keyframesTransitions have no intermediate control points and cannot repeat.
Motion tied to scroll position rather than to timescroll-driven animationsanimation-timeline replaces the time source entirely.
Elements morphing across a page or state changeview transitionsTransitions cannot animate between two different DOM trees.
Runtime-computed values, sequencing that depends on data, or playback controlthe Web Animations APICSS has no way to pause, reverse, or seek imperatively.

In practice these compose rather than compete: a transition handles the hover on a card while a keyframe animation runs the spinner inside it, and both read their durations from the same token layer.

Graceful fallback strategy: for unsupported discrete properties, pair transition-behavior: allow-discrete with a class-toggle fallback. The guide on transitioning display with allow-discrete shows how to combine this with @starting-style for flicker-free enter and exit animations.


Browser Support & Progressive Enhancement

FeatureSupportNotes
transition (shorthand)Universal across current enginesUnprefixed everywhere in use today
transition-behavior: allow-discreteChrome 117+, Edge 117+, Safari 17.4+, Firefox 129+Enables discrete property transitions
@starting-styleChrome 117+, Edge 117+, Safari 17.5+, Firefox 129+Supplies the missing before-change style
will-changeUniversal across current enginesMemory-heavy; scope carefully
prefers-reduced-motionChrome 74+, Firefox 63+, Safari 10.1+, Edge 79+Critical for WCAG 2.1 AA compliance
linear() easingChrome 113+, Edge 113+, Safari 17.2+, Firefox 112+Spring-like curves without JavaScript

Progressive Enhancement Pattern:

/* Base: Instant state change (works everywhere) */
.btn {
  background: #333;
}
.btn:hover {
  background: #555;
}

/* Enhanced: Smooth transition (modern browsers) */
@media (prefers-reduced-motion: no-preference) {
  .btn {
    transition: background-color 0.2s ease;
  }
}

DevTools debugging workflow

Work through these in order; the first two catch most problems in under a minute.

  1. Confirm the property is actually changing. Open the Elements panel, select the element, and use the :hov (Chrome/Edge) or state-toggle (Firefox) control to force :hover. Watch the Computed pane. If the value does not change, the problem is your selector or specificity, not the transition.
  2. Confirm the transition is registered. With the element still selected, check the Computed pane for transition-property and transition-duration. A duration of 0s means the shorthand was overridden or the property name is misspelled — a typo in transition-property fails silently.
  3. Slow it down. Chrome and Edge expose an Animations panel; open it from the DevTools command menu, trigger the interaction, and set playback speed to 25% or 10%. Each transition appears as a bar you can scrub, which makes off-by-a-frame timing and mis-set delays obvious.
  4. Check what the frame costs. Open the Rendering panel and enable Paint flashing and Layer borders. Trigger the transition. Green flashes across the whole element on every frame mean you are repainting, not compositing. A blue layer border around the element confirms it has been promoted.
  5. Profile if it is still janky. Record a Performance trace across the interaction. Expand the Main track and look for repeated Layout or Paint entries aligned with the transition's frames — the profiling animations in DevTools guide walks through reading that flame chart.
  6. Test the reduced-motion path. In the Rendering panel, set Emulate CSS media feature prefers-reduced-motion to reduce and repeat the interaction. Every state change must still be visible.

Common pitfalls

PitfallCauseResolution
Animates in, snaps outtransition declared only inside the :hover/state ruleDeclare it on the base selector; override in the state rule only for asymmetric timing
Nothing animates on insertionAn element's first style computation has no before-change styleUse @starting-style, or set the resting state in the base rule and change it afterwards
Transition silently ignoredProperty is discrete (display, visibility, content-visibility) or a value pair is not interpolable (auto, none)Add transition-behavior: allow-discrete, or transition an interpolable stand-in such as opacity or transform
Jank on large elementsAnimating width, height, top, left, padding, or box-shadowRefactor to transform and fade a pre-rendered shadow layer with opacity
Timing drifts between componentsDurations hard-coded at each call siteCentralise in inherited custom properties and override per component
will-change degrades performancePersistent layer promotion across many elementsScope it to the parent's :hover, or drop it entirely and measure again

Frequently Asked Questions

When should I use CSS transitions instead of @keyframes? Use transitions for state-driven changes (hover, focus, toggle) where the start and end states are known. Use @keyframes for complex, multi-step, or time-driven sequences that require intermediate keyframes or looping.

How do I prevent transition flicker on initial page load? Transitions never run on an element's first style computation, so a flicker almost always means a class or attribute changed in the same frame as insertion. Set the resting state in the base rule and let the interaction change it, or declare an explicit before-change style with @starting-style.

Does transition: all negatively impact performance? Yes. Using all forces the browser to monitor every CSS property for changes, increasing computational overhead during state changes. Always explicitly declare transition-property to limit monitoring to compositor-safe properties like transform and opacity.

Why does my transition only run one way? Because the transition shorthand is declared inside the state rule rather than the base rule. A rule that only exists on :hover disappears the moment the pointer leaves, so there is nothing left to time the return. Declare transition on the base selector and change only the animated values in the state rule.

How do I respect prefers-reduced-motion with CSS transitions? Declare the resting and state styles unconditionally, then add durations only inside a prefers-reduced-motion: no-preference block. That way the interface still changes state for everyone and only the motion is opt-in.


Related articles

More pages in the same section.