CSS Animations and Transitions: A Complete Practical Guide
CSS animations are one of the most overlooked performance opportunities in web development. Done right, they run at 60fps on the GPU with zero JavaScript. Done wrong, they stutter, cause layout thrashing, and drain mobile batteries. This guide teaches you to build smooth, accessible animations the right way.
CSS Transitions
Transitions animate a property from one value to another when it changes (usually via a class change or hover):
css/* Syntax: transition: property duration timing-function delay */ .button { background: #3b82f6; transform: scale(1); transition: background 200ms ease, transform 150ms ease; } .button:hover { background: #2563eb; transform: scale(1.05); } /* Transition all animatable properties */ .card { transition: all 300ms ease-in-out; } /* Multiple transitions with different timings */ .menu { opacity: 0; transform: translateY(-8px); transition: opacity 200ms ease, transform 200ms cubic-bezier(0.16, 1, 0.3, 1); } .menu.open { opacity: 1; transform: translateY(0); }
Timing Functions
css/* Built-in keywords */ transition-timing-function: ease; /* slow-fast-slow (default) */ transition-timing-function: linear; /* constant speed */ transition-timing-function: ease-in; /* starts slow */ transition-timing-function: ease-out; /* ends slow */ transition-timing-function: ease-in-out; /* slow start and end */ /* Cubic bezier for custom easing */ transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); /* spring-like */ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* Material Design */ /* Steps for frame-by-frame */ transition-timing-function: steps(4, end); /* 4-frame animation */
Use tools like cubic-bezier.com to design custom easing curves visually.
CSS Keyframe Animations
Keyframes define multi-step animations that run automatically:
css/* Define the animation */ @keyframes fade-in { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } @keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05); } } @keyframes skeleton-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } /* Apply the animation */ .hero-title { animation-name: fade-in; animation-duration: 600ms; animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); animation-delay: 100ms; animation-fill-mode: both; /* apply styles before/after */ animation-iteration-count: 1; /* or: infinite */ animation-direction: normal; /* or: reverse, alternate */ } /* Shorthand */ .hero-title { animation: fade-in 600ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both; } /* Multiple animations */ .loader { animation: spin 1s linear infinite, pulse 2s ease-in-out infinite alternate; }
animation-fill-mode
One of the most commonly misunderstood properties:
css/* none: element returns to pre-animation state after it ends */ /* forwards: element stays at the final keyframe state */ /* backwards: element starts at the first keyframe state (even during delay) */ /* both: combination of forwards and backwards */ .element { animation: fade-in 400ms ease 200ms both; /* "both" means: show opacity:0 during the 200ms delay, and keep opacity:1 after the animation completes */ }
Performance: Only Animate Cheap Properties
The single most important animation rule: only animate transform and opacity.
| Property | Cost | Why |
|---|---|---|
transform | Cheap β | GPU-composited, no layout |
opacity | Cheap β | GPU-composited, no layout |
color, background | Medium | Repaint only |
width, height | Expensive β | Triggers layout (reflow) |
top, left | Expensive β | Triggers layout |
margin, padding | Expensive β | Triggers layout |
css/* Bad: animates width, triggers layout on every frame */ .panel { width: 0; transition: width 300ms ease; } .panel.open { width: 300px; } /* Good: animate transform, GPU-accelerated */ .panel { transform: scaleX(0); transform-origin: left; transition: transform 300ms ease; } .panel.open { transform: scaleX(1); }
will-change
Hint to the browser to promote an element to its own compositor layer:
css.animated-element { will-change: transform, opacity; }
Use sparingly β every composited layer consumes GPU memory. Only add it to elements that will definitely animate, and remove it after animation ends.
Practical Animation Patterns
Skeleton Loading
css.skeleton { background: linear-gradient( 90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75% ); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; border-radius: 4px; } @keyframes skeleton-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
Toast / Notification
css@keyframes slide-in-right { from { transform: translateX(110%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slide-out-right { from { transform: translateX(0); opacity: 1; } to { transform: translateX(110%); opacity: 0; } } .toast { animation: slide-in-right 300ms cubic-bezier(0.16, 1, 0.3, 1) both; } .toast.dismissing { animation: slide-out-right 200ms ease-in both; }
Staggered List Animation
css@keyframes list-item-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .list-item { animation: list-item-in 400ms ease both; } /* Stagger with CSS custom property */ .list-item:nth-child(1) { animation-delay: calc(1 * 50ms); } .list-item:nth-child(2) { animation-delay: calc(2 * 50ms); } .list-item:nth-child(3) { animation-delay: calc(3 * 50ms); } /* Or with JS: element.style.setProperty('--index', index) */ .list-item { animation-delay: calc(var(--index, 0) * 50ms); }
Smooth Height Transition
Height transitions are tricky because height: auto cannot be transitioned. Use max-height or the modern interpolate-size approach:
css/* Workaround: max-height */ .accordion-content { max-height: 0; overflow: hidden; transition: max-height 300ms ease; } .accordion-content.open { max-height: 500px; /* large enough for content */ } /* Modern: interpolate-size (Chrome 129+) */ :root { interpolate-size: allow-keywords; } .accordion-content { height: 0; overflow: hidden; transition: height 300ms ease; } .accordion-content.open { height: auto; /* animates to actual content height */ }
Accessibility: prefers-reduced-motion
Always respect users who have requested reduced motion (epilepsy, vestibular disorders):
css/* Animations enabled by default */ .card { transition: transform 300ms ease, box-shadow 300ms ease; } .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.15); } @keyframes spin { to { transform: rotate(360deg); } } .spinner { animation: spin 1s linear infinite; } /* Disable or simplify for users who prefer reduced motion */ @media (prefers-reduced-motion: reduce) { * { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } .spinner { animation: none; /* Show a static indicator instead */ } }
Common Interview Questions
Q: Why should you avoid animating layout properties like width and height?
Animating layout-triggering properties (width, height, top, left, margin, padding) forces the browser to recalculate the layout (reflow) and repaint on every animation frame. This is CPU-intensive and frequently causes dropped frames. transform and opacity bypass layout and paint entirely β they are handled by the GPU compositor, enabling smooth 60fps animations.
Q: What is the difference between CSS transitions and CSS animations?
Transitions animate a property between two states in response to a trigger (hover, class change) β they require a start state and an end state. Animations use keyframes to define multiple intermediate states and can run automatically, loop, reverse, and delay without any trigger. Transitions are simpler for two-state changes; animations are more powerful for complex, multi-step, or auto-playing effects.
Q: What does animation-fill-mode: both do?
both combines forwards and backwards. backwards applies the initial keyframe values during the animation delay period (so the element starts in its animated start state immediately, before the animation begins). forwards keeps the final keyframe values after the animation completes (so the element does not snap back to its original state). Together they ensure the animation feels seamless from the first frame to the last.
Practice on Froquiz
CSS and frontend knowledge is tested in frontend and full-stack developer interviews. Test your CSS knowledge on Froquiz β covering layout, animations, and modern CSS features.
Summary
- Transitions animate between two states on a trigger; keyframe animations define multi-step sequences
- Only animate
transformandopacityfor GPU-composited, layout-free 60fps animations animation-fill-mode: bothprevents snapping before and after an animation- Use
cubic-bezierfor natural-feeling easing β avoid linear for UI animations will-change: transformpromotes to compositor layer β use sparingly- Always add
@media (prefers-reduced-motion: reduce)to disable or simplify animations - Stagger animations with
animation-delayand CSS custom properties for polished list entrances