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
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: infinite — transition 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 properties — transform 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 interpolatestransformmatrices 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, theanimationwould win and thetransitionwould be silently ignored.
Verification checklist
Constraints and trade-offs
transitioncannot loop. Workarounds require JavaScript (setInterval+ class toggle) and will desynchronize from the browser’s compositor clock under load.@keyframesincurs a one-time parse cost. Declaring hundreds of keyframe rules in a large design system increases stylesheet parse time. Scope rules to components.will-changeconsumes 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
animationandtransitionon the same property. Theanimationalways wins; thetransitionproduces 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
animationis 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 useanimation-timeline: scroll()are Chromium-only as of early 2026 — do not replacetransition-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.
Related
- CSS Transitions vs Animations — parent: execution model differences and rendering pipeline overview
- Hardware-Accelerated Properties — which CSS properties stay on the GPU thread and how to audit layer promotion
- Avoiding Layout Thrashing in CSS Animations — step-by-step DevTools workflow to eliminate main-thread bottlenecks