CSS Transitions vs Animations: Execution Models & Rendering Impact

Part of Core CSS Animation Fundamentals — the reference for understanding how the browser schedules, composites, and renders CSS motion.

CSS transitions and @keyframes animations are the two native CSS motion primitives, and choosing between them is not a matter of taste. They operate on fundamentally different execution models, impose different costs on the browser’s rendering pipeline, and suit different interaction patterns. Getting this choice right is the first decision in any performance-budgeting workflow.


Left: a transition reacts to a state-change trigger and interpolates once between two values. Right: a keyframe animation is compiled at attach time and the compositor drives it frame-by-frame, independently of the main thread.

Concept Definition

A CSS transition interpolates between two computed property values when a CSS property changes on an element. It requires an explicit start state, an end state, a duration, and a trigger — typically a pseudo-class like :hover or a class toggled via JavaScript. It is inherently reactive: no trigger, no motion.

A CSS animation uses @keyframes to declare an explicit multi-step timeline that attaches to an element via animation-name. It is self-contained — it starts as soon as the rule is applied, runs for as many iterations as animation-iteration-count specifies, and can persist its end state with animation-fill-mode. No external trigger is required.

The rendering problem each solves is different. Transitions minimise code for simple A→B state feedback. Animations express autonomous, looping, or sequenced motion that a transition cannot represent without JavaScript.

Execution Model

CSS Transitions

When a transition-eligible property changes (because a pseudo-class matches or a class is toggled), the browser performs a style recalculation on the main thread to determine the before and after computed values. It then hands the interpolation to the compositor thread, which produces each frame without further main-thread involvement — provided the animated property is transform or opacity. If you animate a layout property like width, the main thread must recalculate layout at every frame, which is what causes jank.

The compositor queries the easing function specified by transition-timing-function to produce the value at each frame. When the transition completes, the element settles at the final computed value and no further work occurs.

CSS Animations

@keyframes rules are parsed once when the stylesheet is loaded and compiled into an internal representation. When an animation-name is applied to an element, the browser runs one style recalculation on the main thread to attach the animation, then the compositor takes over. For compositor-only properties, every subsequent frame is produced by the GPU with zero main-thread involvement — this is the key to smooth 60 fps looping animations.

Invalidations can force the animation back to the main thread. Adding or removing animation-name in JavaScript, changing animation-duration mid-run, or mutating an animated property directly in JS all cause a style recalculation. Keep the animation declaration stable after attachment.

Property & API Reference Table

Property / Concept Transitions Animations Compositing Tier Notes
transition-property varies Restrict to transform, opacity for GPU path
transition-duration Sets total interpolation time
transition-timing-function ✓ via animation-timing-function Applies per keyframe segment in animations
transition-delay ✓ via animation-delay Negative delay starts mid-animation
animation-name References a @keyframes block
animation-iteration-count infinite for looping; transitions cannot loop
animation-fill-mode forwards persists end state; no equivalent in transitions
animation-play-state paused freezes at current frame
animation-direction alternate reverses on odd iterations
will-change composite Pre-promotes layer; remove after motion ends
@keyframes Defines intermediate states; transitions have none

Annotated Code Examples

Transition: composited hover response

/* Intent: card lifts on hover using compositor-only properties */
.card {
  /* Declare both animated properties together to batch compositor work */
  transition:
    transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
    opacity   0.25s ease;
}

.card:hover {
  transform: translateY(-4px) scale(1.02);
  opacity: 0.92;
}

/* Accessibility gate: honour vestibular-disorder preference */
@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
}

Rendering Impact: compositetransform and opacity bypass layout and paint. The compositor produces every interpolated frame on the GPU without touching the main thread.

Animation: autonomous looping pulse

/* Intent: draw attention to a notification badge with a looping scale pulse */
@keyframes pulse-glow {
  /* Compositor-only keyframes: only transform and opacity change */
  0%,
  100% { transform: scale(1);    opacity: 1;    }
  50%  { transform: scale(1.07); opacity: 0.82; }
}

.notification-badge {
  animation: pulse-glow 2s infinite ease-in-out;
  /*
   * will-change pre-promotes the element before the first frame,
   * preventing a first-frame composite-layer miss.
   * IMPORTANT: remove or unset this when the badge is dismissed
   * to release the GPU memory allocation.
   */
  will-change: transform, opacity;
}

@media (prefers-reduced-motion: reduce) {
  .notification-badge {
    animation: none;
    will-change: auto; /* release layer when motion is off */
  }
}

Rendering Impact: composite — the compositor runs all frames. will-change pre-allocates the layer to avoid first-frame jank.

Animation fill-mode: persist end state without JavaScript

/* Intent: fade an element in on load and hold it visible */
@keyframes fade-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0);   }
}

.hero-text {
  /*
   * animation-fill-mode: forwards keeps the element at the `to`
   * keyframe values after the animation ends.
   * Without this, the element snaps back to opacity:0 on completion.
   */
  animation: fade-in 0.4s ease-out forwards;
}

@media (prefers-reduced-motion: reduce) {
  /* Skip the animation and show the element immediately */
  .hero-text {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: compositeopacity and transform keep this on the compositor path. The forwards fill holds the end state without JS.

DevTools Workflow

Use Chrome DevTools to verify your motion runs on the compositor thread and not on the main thread.

  1. Open DevTools → Performance panel. Click the settings gear and enable Screenshots and Web Vitals.
  2. Click Record, trigger the interaction (hover over the element, or wait for the animation to cycle), then stop recording.
  3. In the flame chart, look at the Main thread row. You should see no tasks that correspond to your animated frames. If you see repeated Update Layer Tree or Paint entries firing at 60 Hz, a non-composited property is being animated.
  4. Switch to the Layers panel (More tools → Layers). Locate the animated element. It should appear as a distinct layer. The reason shown should be "will-change" or "transform with 3D". If it shares a layer with its parent, the compositor cannot isolate its repaints.
  5. In the Animation panel (More tools → Animations), you can slow the playback rate to 10% or 25% to inspect easing curves visually and confirm keyframe timing matches your intent.
  6. For timing function debugging, click the cubic-bezier icon next to a transition-timing-function or animation-timing-function declaration in the Styles panel to open the curve editor and adjust it interactively.

Firefox DevTools: Open the Animation panel in the Inspector sidebar. It shows a timeline of every running animation and transition on the page, including their easing curves and playback state. The Performance panel shows compositor thread activity in the same way as Chrome.

Failure Modes & Fixes

Problem: Transition feels laggy on first trigger. Root cause: The element has not been promoted to a compositor layer before the interaction. The browser must create the layer on demand during the first frame, causing a visible delay. Fix: Add will-change: transform to the element’s default style (not the :hover rule). This pre-promotes the layer before any user interaction. Remove will-change if the element is unlikely to be interacted with frequently to avoid holding GPU memory.


Problem: Animation causes dropped frames on scroll. Root cause: The animation is running on an element that the browser composites on the same layer as scrolling content. Compositor work for the animation and scroll compete. Fix: Ensure the animated element has its own layer (will-change: transform or transform: translateZ(0)). Also verify the animation only touches transform and opacity. Avoid animating background-color, box-shadow, or border-radius, which require repaint.


Problem: Both a transition and an animation are declared on the same property, and the transition never runs. Root cause: animation declarations win over transition in the cascade for the same property. This is not a specificity issue — it is part of the CSS cascade specification. Fix: Either remove the transition for that property and rely solely on the animation, or ensure the transition and animation target different properties. A common pattern: use animation for the looping effect on transform, and transition on opacity for the hover state.


Problem: Animation snaps back to its initial state after completion. Root cause: animation-fill-mode defaults to none, which means the element returns to its pre-animation style when the animation ends. Fix: Add animation-fill-mode: forwards to hold the to keyframe values after the animation completes, or both to also hold the from values during the animation-delay period.


Problem: animation-play-state: paused causes a visual jump. Root cause: The pause was applied mid-interpolation and the element’s base style differs from the paused keyframe value. When paused, the element snaps to its base style rather than holding its in-progress position. Fix: Verify animation-fill-mode: both is set so the element holds keyframe values at all times, including while paused. This ensures the visual position is always driven by the @keyframes definition, not the base style.

Accessibility & Reduced-Motion

The prefers-reduced-motion media query reflects the operating-system-level preference set by users who experience vestibular disorders, motion sickness, or epilepsy risk from animated content. Both transitions and animations must respect it.

The safest approach is an opt-in pattern: wrap all motion in a prefers-reduced-motion: no-preference block, so users who have not opted in to motion never see it:

/* Opt-in pattern: motion off by default, enabled only when the OS has no preference */
.card {
  /* Base style: no motion */
}

@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.25s ease, opacity 0.25s ease;
  }
  .card:hover {
    transform: translateY(-4px);
  }
}

For animations, the opt-in pattern looks like this:

@media (prefers-reduced-motion: no-preference) {
  .notification-badge {
    animation: pulse-glow 2s infinite ease-in-out;
    will-change: transform, opacity;
  }
}

Alternatively, the opt-out pattern explicitly disables motion for users who prefer reduced motion. This is acceptable when your base styles already include motion declarations and you want to override them:

/* Opt-out pattern */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration:   0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration:  0.01ms !important;
    scroll-behavior:      auto   !important;
  }
}

The opt-in pattern is preferable because it makes no-motion the baseline and never risks flashing content during a transition from animated to no-motion state.

For the detailed decision tree on which pattern to apply per interaction type, see when to use transition vs keyframe animation.

FAQ

Can I use both transitions and animations on the same element?

Yes, provided they target different properties. If both target the same property, the animation declaration wins in the cascade and the transition is silently ignored. Use transitions for micro-interactions on hover and focus, and animations for continuous or multi-step sequences on separate properties.

Why do CSS animations feel janky on mobile?

Mobile GPUs have limited VRAM and thermal headroom. Animating layout-triggering properties forces recalculation and repaint on the main thread at every frame. Restrict animated properties to transform and opacity, use will-change sparingly, and profile on a real device to catch thermal throttling.

How do I pause a CSS animation without losing its current position?

Set animation-play-state: paused via an inline style or a toggled class. This freezes the animation at its current keyframe position. Removing animation-name entirely resets the element to its base style, which is usually not what you want.

Does will-change help transitions as well as animations?

Yes. will-change: transform promotes an element to its own compositor layer before any motion begins, reducing first-frame latency for both transitions and animations. Remove it after motion ends to free GPU memory — persistent will-change declarations on many elements cause layer explosion, which is covered in the layer promotion strategy reference.

When should I use JavaScript instead of pure CSS motion?

Use the Web Animations API or JS-driven state when you need precise playback control (seek, reverse, pause at a specific offset), physics-based easing not expressible as cubic-bezier, or synchronised coordination across many elements. Pure CSS is preferable for predictable state-driven or looping motion because it requires no JS parse overhead and hands execution to the compositor immediately.