Why CSS Animations?
Animations communicate state changes, guide attention, and make interfaces feel alive. CSS handles most animation needs without JavaScript, running on the browser's compositor thread for smooth 60fps performance.
The two animation systems:
- Transitions โ animate between two states (A to B) when a property changes.
- Keyframe animations โ define complex multi-step animations with full timeline control.
Both rely on the transform property for performant visual changes.
CSS Transitions
Transitions smoothly animate a property from its current value to a new value when that value changes (via hover, class toggle, focus, etc.).
Basic Syntax
.button {
background: #3b82f6;
transition: background 0.3s ease;
}
.button:hover {
background: #2563eb;
}
The transition shorthand:
/* transition: property duration timing-function delay */
.element {
transition: opacity 0.3s ease 0s;
transition: all 0.3s ease; /* all animatable properties */
transition: transform 0.2s ease-out;
}
Multiple Transitions
.card {
transition:
transform 0.3s ease,
box-shadow 0.3s ease,
opacity 0.2s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
opacity: 0.95;
}
Individual Properties
.element {
transition-property: transform, opacity;
transition-duration: 0.3s, 0.2s;
transition-timing-function: ease, linear;
transition-delay: 0s, 0.1s;
}
What Can Be Transitioned?
Not all CSS properties are animatable. Broadly:
Animatable:
opacity, transform, color, background-color, border-color,
width, height, padding, margin, top, right, bottom, left,
box-shadow, text-shadow, font-size, line-height, letter-spacing,
border-radius, outline-color, outline-width, outline-offset,
filter, clip-path
Not animatable:
display, font-family, position, overflow,
grid-template-columns (partially), z-index (integer steps)
Transition Triggers
Transitions need a trigger โ a change in property value:
/* Trigger: hover */
.element:hover { transform: scale(1.05); }
/* Trigger: focus */
.input:focus { border-color: blue; }
/* Trigger: class toggle (via JavaScript) */
.element.active { opacity: 1; transform: translateY(0); }
/* Trigger: checked state */
.checkbox:checked + .label { color: green; }
/* Trigger: media query (not recommended โ can feel jarring) */
@media (min-width: 768px) {
.sidebar { width: 250px; }
}
Transition Patterns
Fade in/out:
.modal {
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.modal.open {
opacity: 1;
visibility: visible;
}
Note: display: none can't be transitioned. Use opacity + visibility instead. Or use the newer transition-behavior: allow-discrete (Chrome 117+):
/* New approach (limited browser support) */
.modal {
display: none;
opacity: 0;
transition: opacity 0.3s, display 0.3s;
transition-behavior: allow-discrete;
}
.modal.open {
display: block;
opacity: 1;
}
Slide down:
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease;
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
Color transition on hover:
.link {
color: #3b82f6;
transition: color 0.2s ease;
}
.link:hover {
color: #1d4ed8;
}
CSS Transforms
The transform property applies 2D or 3D transformations. Transforms are the backbone of performant CSS animations because they don't trigger layout recalculation.
2D Transforms
.element {
/* Move */
transform: translateX(50px);
transform: translateY(20px);
transform: translate(50px, 20px); /* x, y */
transform: translate(-50%, -50%); /* percentage of element size */
/* Scale */
transform: scale(1.5); /* 150% both axes */
transform: scaleX(2); /* 200% horizontal */
transform: scale(1.2, 0.8); /* x, y */
/* Rotate */
transform: rotate(45deg);
transform: rotate(-90deg);
transform: rotate(0.5turn);
/* Skew */
transform: skewX(10deg);
transform: skew(10deg, 5deg); /* x, y */
}
Combining Transforms
/* Multiple transforms in one declaration โ order matters */
.element {
transform: translateX(100px) rotate(45deg) scale(1.2);
}
Order matters because transforms are applied right-to-left. translate then rotate produces a different result than rotate then translate.
translate(100px, 0) then rotate(45deg):
1. Move right 100px
2. Rotate 45deg at new position
rotate(45deg) then translate(100px, 0):
1. Rotate 45deg (axes rotate too)
2. Move 100px along the ROTATED x-axis (diagonal)
Individual Transform Properties (Modern)
Modern CSS lets you set transforms individually โ no more overwriting the entire transform:
.element {
translate: 100px 20px;
rotate: 45deg;
scale: 1.2;
}
/* These can be transitioned independently */
.element {
transition: translate 0.3s, rotate 0.5s, scale 0.2s;
}
.element:hover {
translate: 0 -10px;
scale: 1.05;
}
Browser support: Chrome 104+, Firefox 72+, Safari 14.1+, Edge 104+.
transform-origin
The point around which transforms are applied. Default is center center.
.element {
transform-origin: center center; /* default */
transform-origin: top left; /* rotate from top-left corner */
transform-origin: 0 0; /* same as top left */
transform-origin: 50% 100%; /* bottom center */
}
transform-origin: center transform-origin: top left
+----+ *----+
| / | |\ |
|/ * | <- rotates | \ |
| | around | \ |
+----+ center +----+
3D Transforms
/* Enable 3D space on parent */
.parent {
perspective: 1000px; /* depth of 3D effect */
perspective-origin: 50% 50%; /* vanishing point */
}
/* 3D transforms on children */
.child {
transform: rotateX(30deg); /* tilt forward/backward */
transform: rotateY(45deg); /* turn left/right */
transform: rotateZ(90deg); /* same as rotate() */
transform: rotate3d(1, 1, 0, 45deg); /* arbitrary axis */
transform: translateZ(50px); /* move toward/away from viewer */
}
/* Preserve 3D for nested transforms */
.parent {
transform-style: preserve-3d;
}
/* Hide back of flipped elements */
.card-face {
backface-visibility: hidden;
}
Card flip animation:
.card {
perspective: 1000px;
width: 200px;
height: 300px;
}
.card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}
.card:hover .card-inner {
transform: rotateY(180deg);
}
.card-front, .card-back {
position: absolute;
inset: 0;
backface-visibility: hidden;
}
.card-back {
transform: rotateY(180deg);
}
Keyframe Animations
For animations that run automatically, loop, or have multiple steps.
Basic Keyframes
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.element {
animation: fadeIn 0.5s ease forwards;
}
Multi-Step Keyframes
@keyframes bounce {
0% { transform: translateY(0); }
25% { transform: translateY(-20px); }
50% { transform: translateY(0); }
75% { transform: translateY(-10px); }
100% { transform: translateY(0); }
}
.bouncing {
animation: bounce 1s ease infinite;
}
Animation Shorthand
/* animation: name duration timing-function delay iteration-count
direction fill-mode play-state */
.element {
animation: slideIn 0.5s ease-out 0.2s 1 normal forwards running;
}
Animation Properties (Individual)
.element {
animation-name: slideIn;
animation-duration: 0.5s;
animation-timing-function: ease-out;
animation-delay: 0.2s;
animation-iteration-count: 1; /* or infinite */
animation-direction: normal; /* normal, reverse, alternate */
animation-fill-mode: forwards; /* none, forwards, backwards, both */
animation-play-state: running; /* running, paused */
}
animation-fill-mode Explained
Controls what happens before and after the animation:
/* none (default): element reverts to original state after animation */
animation-fill-mode: none;
/* forwards: element keeps the final keyframe state */
animation-fill-mode: forwards;
/* backwards: element takes the first keyframe state during delay */
animation-fill-mode: backwards;
/* both: combines forwards and backwards */
animation-fill-mode: both;
fill-mode: none
Before: [original] -> During: [animated] -> After: [original]
fill-mode: forwards
Before: [original] -> During: [animated] -> After: [final keyframe]
fill-mode: backwards
Before: [first keyframe] -> During: [animated] -> After: [original]
fill-mode: both
Before: [first keyframe] -> During: [animated] -> After: [final keyframe]
animation-direction
/* normal: 0% -> 100% */
animation-direction: normal;
/* reverse: 100% -> 0% */
animation-direction: reverse;
/* alternate: 0% -> 100% -> 0% -> 100% ... */
animation-direction: alternate;
/* alternate-reverse: 100% -> 0% -> 100% -> 0% ... */
animation-direction: alternate-reverse;
Multiple Animations
.element {
animation:
fadeIn 0.5s ease forwards,
slideUp 0.5s ease 0.2s forwards;
}
Practical Animation Examples
Spinner:
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
Pulse:
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton {
animation: pulse 2s ease-in-out infinite;
background: #e5e7eb;
border-radius: 4px;
}
Slide in from left:
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.slide-in {
animation: slideInLeft 0.4s ease-out forwards;
}
Staggered list animation:
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.list-item {
opacity: 0;
animation: fadeInUp 0.4s ease forwards;
}
.list-item:nth-child(1) { animation-delay: 0.0s; }
.list-item:nth-child(2) { animation-delay: 0.1s; }
.list-item:nth-child(3) { animation-delay: 0.2s; }
.list-item:nth-child(4) { animation-delay: 0.3s; }
.list-item:nth-child(5) { animation-delay: 0.4s; }
Shake (error feedback):
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
20%, 40%, 60%, 80% { transform: translateX(4px); }
}
.error {
animation: shake 0.5s ease;
}
Timing Functions
Timing functions control the speed curve of animations and transitions.
Built-in Functions
.element {
transition-timing-function: ease; /* slow start, fast middle, slow end */
transition-timing-function: ease-in; /* slow start */
transition-timing-function: ease-out; /* slow end */
transition-timing-function: ease-in-out; /* slow start and end */
transition-timing-function: linear; /* constant speed */
}
ease: ___/----\___
ease-in: ____/------
ease-out: ------\____
ease-in-out: ___/----\___ (more pronounced than ease)
linear: /----------
cubic-bezier()
Custom timing curves with two control points:
.element {
/* cubic-bezier(x1, y1, x2, y2) */
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); /* Material ease */
transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1); /* default ease */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55); /* back effect */
}
Useful presets:
/* Snappy entrance */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
/* Smooth settle */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
/* Bounce back */
--ease-back: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Spring-like */
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
steps()
Creates discrete jumps instead of smooth transitions:
/* Steps: jump between frames */
.element {
transition-timing-function: steps(4); /* 4 equal jumps */
transition-timing-function: steps(4, end); /* jump at end of step */
transition-timing-function: steps(4, start); /* jump at start of step */
transition-timing-function: step-start; /* same as steps(1, start) */
transition-timing-function: step-end; /* same as steps(1, end) */
}
Use case: sprite sheet animation:
.sprite {
width: 64px;
height: 64px;
background: url('spritesheet.png');
animation: walk 0.8s steps(8) infinite;
}
@keyframes walk {
to { background-position: -512px 0; } /* 8 frames * 64px */
}
Performance: GPU Acceleration
Not all CSS properties animate at the same performance cost.
The Rendering Pipeline
Style -> Layout -> Paint -> Composite
| | | |
CSS Position Pixels GPU layers
calc & size drawn combined
Layout properties (expensive):
width, height, padding, margin, top, left, right, bottom,
font-size, border-width, display, position
Changing these triggers the full pipeline: layout -> paint -> composite.
Paint properties (moderate):
color, background, box-shadow, text-shadow, border-color,
border-style, outline, visibility
Changing these triggers paint -> composite (skips layout).
Composite properties (cheap):
transform, opacity
These only trigger compositing. The GPU handles them on a separate thread. Always prefer transform and opacity for animations.
The will-change Hint
will-change tells the browser to prepare for an animation, promoting the element to its own GPU layer:
.element {
will-change: transform; /* browser creates a GPU layer in advance */
}
Rules for will-change:
- Don't apply it to everything โ each GPU layer uses memory.
- Apply it before the animation starts, not during.
- Remove it when the animation is done.
- Use it sparingly โ browsers already optimize common animations.
/* Good: apply on hover intent, not all the time */
.card {
transition: transform 0.3s ease;
}
.card:hover {
will-change: transform;
transform: translateY(-4px);
}
/* Better: apply in JS when animation is about to start */
/* Bad: applying to everything wastes GPU memory */
* {
will-change: transform; /* DON'T do this */
}
Forcing GPU Acceleration
/* Old hack (still works) */
.element {
transform: translateZ(0); /* creates a GPU layer */
/* or */
transform: translate3d(0, 0, 0);
}
This is the older technique before will-change existed. Use will-change instead when possible.
Measuring Performance
Use Chrome DevTools:
- Performance tab โ Record animation, look for long frames (>16.67ms).
- Rendering tab โ Enable "Paint flashing" to see what repaints.
- Layers panel โ See GPU layers and their memory usage.
Animation Performance Rules
DO animate:
- transform (translate, scale, rotate)
- opacity
- filter (GPU-accelerated in most browsers)
- clip-path (GPU-accelerated in Chromium)
AVOID animating:
- width, height (triggers layout)
- top, left, right, bottom (triggers layout)
- margin, padding (triggers layout)
- border-width (triggers layout)
- font-size (triggers layout)
- box-shadow (triggers paint โ expensive)
/* BAD: animates left (triggers layout every frame) */
.element {
position: relative;
transition: left 0.3s;
}
.element:hover {
left: 100px;
}
/* GOOD: animates transform (GPU-composited) */
.element {
transition: transform 0.3s;
}
.element:hover {
transform: translateX(100px);
}
Accessibility: Reduced Motion
Some users experience motion sickness, vertigo, or seizures from animations. Always respect the prefers-reduced-motion preference.
/* Full animation for users who haven't opted out */
.element {
animation: slideIn 0.5s ease;
transition: transform 0.3s ease;
}
/* Reduced or no animation for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.element {
animation: none;
transition: none;
}
}
Alternative: reduce rather than remove:
@media (prefers-reduced-motion: reduce) {
.element {
animation-duration: 0.01ms;
transition-duration: 0.01ms;
}
}
Global approach:
@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;
}
}
Practical Patterns
Button Hover Effect
.button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.button:active {
transform: translateY(0);
box-shadow: none;
}
Page Transition (Fade)
.page-enter {
opacity: 0;
transform: translateY(10px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.page-exit {
opacity: 1;
}
.page-exit-active {
opacity: 0;
transition: opacity 0.2s ease;
}
Animated Underline
.nav-link {
position: relative;
text-decoration: none;
}
.nav-link::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background: currentColor;
transform: scaleX(0);
transform-origin: right;
transition: transform 0.3s ease;
}
.nav-link:hover::after {
transform: scaleX(1);
transform-origin: left;
}
Notification Entrance
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOutRight {
to {
transform: translateX(100%);
opacity: 0;
}
}
.notification-enter {
animation: slideInRight 0.3s ease-out forwards;
}
.notification-exit {
animation: slideOutRight 0.3s ease-in forwards;
}
Transition vs Animation: When to Use Which
| Aspect | Transition | Keyframe Animation |
|---|---|---|
| Trigger | Property change (hover, class) | Automatic or event-based |
| States | Two (start, end) | Multiple (keyframes) |
| Looping | No | Yes (infinite) |
| Auto-start | No (needs trigger) | Yes (on load or class add) |
| Control | Limited | Full (pause, direction, fill) |
| Complexity | Simple | Complex multi-step |
| Performance | Same | Same |
Use transitions for:
- Hover effects
- Focus states
- Class-toggled state changes
- Simple A-to-B animations
Use keyframes for:
- Loading spinners
- Attention-grabbing effects
- Multi-step sequences
- Looping animations
- Page entry animations
Key Takeaways
- Transitions animate between two states when a property changes. Simple and effective for interactive states.
- Keyframe animations handle multi-step, looping, or auto-playing animations.
- Transform and opacity are the only properties that animate cheaply (GPU-composited). Prefer them always.
- Avoid animating layout properties (width, height, top, left, margin, padding).
- Use
will-changesparingly and only before the animation starts. cubic-bezier()creates custom easing curves. Use tools like cubic-bezier.com to design them.- Always respect
prefers-reduced-motionโ disable or reduce animations for users who need it. - Use
animation-fill-mode: forwardsto keep the end state after animation completes. - Individual transform properties (
translate,rotate,scale) allow independent transitions. - Measure performance with DevTools Performance and Rendering tabs.