When to Use transition vs @keyframes Animation

Part of CSS Transitions vs Animations in Core CSS Animation Fundamentals.

Problem framing

You’ve added motion to a component and something is wrong — hover states snap back under rapid clicks, a loading indicator stutters, or a modal entrance causes a brief flash before settling. Both transition and @keyframes produce motion, yet reaching for the wrong one for the job creates rendering conflicts the browser cannot cleanly resolve. Choosing the correct primitive up front eliminates an entire class of jank before it reaches production.

Root cause: two different execution models

CSS transition is state-driven. It watches a property for a computed-value change, automatically calculates the delta between the old and new value, and interpolates over the specified duration. The browser queues a single style invalidation, then the compositor handles the rest — provided you stay on hardware-accelerated properties.

@keyframes is timeline-driven. The browser parses and compiles the keyframe rule immediately when the animation property is attached, then the compositor delivers frames according to the declared percentages and iteration count. There is no implicit state tracking — the animation runs on its own clock, independent of whether the element’s base styles match any keyframe value.

The mismatch happens when developers use @keyframes for simple binary toggles (paying the parsing + state-sync overhead unnecessarily) or use transition for multi-step sequences (the primitive has no concept of intermediate steps, so approximations with JavaScript timers fight the browser’s state machine).

Rendering pipeline impact

CSS transition vs @keyframes rendering pipeline Two horizontal flow diagrams. The top shows CSS transition skipping layout and paint and going straight to composite. The bottom shows @keyframes also going straight to composite when restricted to transform/opacity, but with an additional parsing step at attachment. transition @keyframes Style Layout ✗ Paint ✗ Composite ✓ GPU thread fast path Parse & Compile Style Layout ✗ Composite ✓ GPU thread one-time cost skipped phase active phase

Both primitives reach the compositor thread when restricted to transform and opacity. The key difference is that @keyframes incurs a one-time parse-and-compile cost at attachment; transition has no upfront cost because the browser calculates the delta lazily when the property changes.

Decision matrix

Work through the questions in order. Stop at the first “yes”.

Question If yes → use Reason
Does the motion animate between exactly two states that CSS can already see? transition Browser calculates the delta implicitly; no authored keyframes needed
Does the motion need to loop continuously without JavaScript? @keyframes animation-iteration-count: infinitetransition cannot loop
Does the motion require more than two intermediate states? @keyframes transition interpolates only start → end; percentage stops require keyframes
Is the motion triggered automatically on page load (no interaction required)? @keyframes transition requires a property change trigger; animations start on attachment
Does the motion need to pause, reverse, or alternate directions on its own? @keyframes animation-direction: alternate and animation-play-state are keyframe-only controls
Is the motion a simple hover, focus, or open/close toggle? transition One style invalidation; lower overhead than a parsed animation rule

Production code pattern

Both examples below target only compositor-safe propertiestransform and opacity — to guarantee the browser skips layout and paint on every frame.

Pattern A — transition for state changes

Use this for hover effects, focus rings, toggled visibility, and modal open/close.

/* Intent: GPU-composited property interpolation for interactive state change.
   Restricting to transform + opacity keeps every frame on the compositor thread. */
.card {
  /* Declare transition on base element, not just the :hover rule.
     This ensures the reverse transition (unhover) also animates smoothly. */
  transition:
    transform 200ms cubic-bezier(0.4, 0, 0.2, 1),
    opacity   200ms ease;

  /* Pre-promote the layer only during likely-interaction states.
     Avoid setting will-change globally — it pins GPU memory permanently. */
  will-change: transform;
}

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

/* Accessibility gate: remove motion for users who prefer reduced motion.
   will-change: auto releases the pre-promoted compositor layer. */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
    will-change: auto;
  }
}

Rendering Impact: composite — style recalculates once on state change; all subsequent frames are compositor-only. No layout or paint work on the main thread.

Pattern B — @keyframes for continuous or multi-step motion

Use this for loading spinners, skeleton shimmer effects, progress indicators, and entrance sequences.

/* Intent: Compositor-safe loop using only transform — no layout properties touched. */
@keyframes pulse {
  /* Percentages define the timeline explicitly.
     Only transform is animated; browser never re-enters paint or layout phase. */
  0%,
  100% { transform: scale(1); }
   50% { transform: scale(1.06); }
}

.status-indicator {
  /* animation-fill-mode: both ensures the element holds the 0% state before
     the animation starts, preventing a pop-in on first frame. */
  animation: pulse 1.8s ease-in-out infinite;
  animation-fill-mode: both;
}

/* Accessibility gate: pause continuous motion entirely.
   Do not just reduce speed — pulsing at any speed can trigger vestibular symptoms. */
@media (prefers-reduced-motion: reduce) {
  .status-indicator {
    animation: none;
  }
}

Rendering Impact: composite — keyframe percentages are compiled once; the compositor interpolates transform matrices without returning to the main thread. Zero layout or paint events during playback.

Combining both on the same element

When an element needs a continuous background animation AND a state-change response, target different properties:

/* @keyframes owns opacity — runs continuously */
@keyframes breathe {
  0%, 100% { opacity: 1; }
     50% { opacity: 0.6; }
}

.badge {
  animation: breathe 2s ease-in-out infinite;

  /* transition owns transform — responds to hover state */
  transition: transform 150ms ease-out;
}

.badge:hover {
  transform: scale(1.1);
}

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

Rendering Impact: composite — both properties stay on the GPU thread. If they ever targeted the same property, the animation would win and the transition would be silently ignored.

Verification checklist

Constraints and trade-offs

  • transition cannot loop. Workarounds require JavaScript (setInterval + class toggle) and will desynchronize from the browser’s compositor clock under load.
  • @keyframes incurs a one-time parse cost. Declaring hundreds of keyframe rules in a large design system increases stylesheet parse time. Scope rules to components.
  • will-change consumes GPU memory per promoted layer. On devices with 1–2 GB RAM this can cause texture eviction mid-animation. Audit layer counts via the DevTools Layers panel before shipping; see avoiding layout thrashing in CSS animations for the full audit workflow.
  • Mixing animation and transition on the same property. The animation always wins; the transition produces no visible effect and wastes style-calculation time. Keep a strict per-property ownership rule.
  • Framework state updates vs CSS animation timing. React and Vue batch DOM updates; if a class controlling an animation is added and removed in the same microtask, the animation may never start. Synchronize via CSS custom properties for state-driven keyframe control or the Web Animations API.
  • Browser support for animation-timeline. The scroll-driven animation patterns that use animation-timeline: scroll() are Chromium-only as of early 2026 — do not replace transition-based fallbacks prematurely.

Frequently asked questions

When should I choose a CSS transition over a @keyframes animation? Use transition for binary or ternary state changes — hover, focus, open/close — where the browser can implicitly calculate the delta between two known states. Transitions queue a single style invalidation and require no authored keyframe rule, so they have lower parsing overhead.

How do I prevent layout thrashing when animating elements? Restrict animated properties to transform and opacity. These are handled exclusively by the compositor thread. Animating width, height, margin, top, or left forces the browser back into the layout phase on every frame, which is the primary cause of dropped frames.

Can I combine transitions and @keyframes on the same element? Yes, provided they target different properties. If both target the same property the animation silently wins. Use CSS custom properties for state mapping to bridge stateful JavaScript updates into keyframe-driven animations without conflict.

Why does my @keyframes animation feel janky on mobile devices? Mobile GPUs enforce stricter texture memory limits. Promoting too many layers via will-change, animating non-composited properties, or running dozens of concurrent animations simultaneously causes texture eviction and dropped frames. Audit via Chrome DevTools Layers panel and reduce concurrent promoted-layer count to single digits.