CSS has two independent systems for motion: transitions (smooth value changes triggered by state) and animations (@keyframes sequences that run automatically). Knowing when to use each — and all their properties — is what separates static UIs from polished ones.
This cheat sheet covers every property with working code, eight common patterns, performance rules, and the mistakes that cause jank.
Quick reference
| Task | Code |
|---|---|
| Fade on hover | transition: opacity 0.3s ease |
| Slide down | transition: transform 0.4s ease + translateY(-100%)→0 |
| Spin forever | animation: spin 1s linear infinite |
| Pulse once | animation: pulse 0.6s ease-out |
| Delay start | transition-delay: 0.2s |
| Fill mode | animation-fill-mode: forwards |
| Pause/play | animation-play-state: paused |
| Reduce motion | @media (prefers-reduced-motion: reduce) |
Part 1 — CSS Transitions
Transitions animate a property from its current value to a new value when that property changes (e.g., on :hover, class toggle, or JS .style change).
The shorthand
.element {
transition: <property> <duration> <timing-function> <delay>;
}
/* Single property */
.btn {
background: blue;
transition: background 0.3s ease;
}
.btn:hover {
background: darkblue;
}
/* Multiple properties */
.card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
/* All properties (use sparingly — hits performance) */
.element {
transition: all 0.3s ease;
}
Transition properties
| Property | Values | Default |
|---|---|---|
transition-property |
all, none, or CSS property names |
all |
transition-duration |
0.3s, 300ms |
0s |
transition-timing-function |
ease, linear, ease-in, ease-out, ease-in-out, cubic-bezier(), steps() |
ease |
transition-delay |
0s, 0.2s |
0s |
Timing functions
/* Built-in keywords */
transition-timing-function: ease; /* slow → fast → slow (default) */
transition-timing-function: linear; /* constant speed */
transition-timing-function: ease-in; /* slow start */
transition-timing-function: ease-out; /* slow end */
transition-timing-function: ease-in-out; /* slow start + slow end */
/* Custom cubic-bezier — use cubic-bezier.com to design */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* spring overshoot */
/* Steps — discrete jumps (typewriter, sprite animations) */
transition-timing-function: steps(4, end);
What you can and cannot transition
Animatable: opacity, transform, color, background-color, width, height, padding, margin, border-radius, box-shadow, font-size, top/left/right/bottom.
Not animatable: display, visibility (use opacity instead), font-family, content, background-image.
Part 2 — CSS Animations (@keyframes)
Animations let you define multi-step sequences that run automatically — no state change needed.
Basic syntax
/* 1. Define the keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* 2. Apply to an element */
.modal {
animation: fadeIn 0.4s ease forwards;
}
The shorthand
animation: <name> <duration> <timing-function> <delay>
<iteration-count> <direction> <fill-mode> <play-state>;
.spinner {
animation: spin 1s linear infinite;
}
.toast {
animation: slideUp 0.3s ease-out 0.5s 1 normal forwards running;
/* name duration timing delay count direction fill-mode play-state */
}
Animation properties
| Property | Values | Purpose |
|---|---|---|
animation-name |
keyframe name | Which @keyframes block |
animation-duration |
0.5s, 1000ms |
How long one cycle takes |
animation-timing-function |
same as transition | Speed curve per cycle |
animation-delay |
0s, 0.2s, -0.5s (negative = start mid-animation) |
When to start |
animation-iteration-count |
1, 3, infinite |
How many times |
animation-direction |
normal, reverse, alternate, alternate-reverse |
Forward, backward, or ping-pong |
animation-fill-mode |
none, forwards, backwards, both |
What values apply outside active period |
animation-play-state |
running, paused |
Control via JS |
animation-fill-mode explained
@keyframes slideDown {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* none — snaps back to original value after animation ends */
/* forwards — stays at the "to" value when done (most common) */
/* backwards — applies "from" value during delay period */
/* both — forwards + backwards */
.element {
animation: slideDown 0.4s ease forwards;
}
Keyframe percentages
@keyframes bounce {
0% { transform: translateY(0); }
30% { transform: translateY(-20px); }
50% { transform: translateY(0); }
70% { transform: translateY(-10px); }
100% { transform: translateY(0); }
}
/* Multiple properties in one keyframe */
@keyframes popIn {
0% { transform: scale(0.8); opacity: 0; }
60% { transform: scale(1.05); }
100% { transform: scale(1); opacity: 1; }
}
Common patterns
1. Fade in
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.page-enter {
animation: fadeIn 0.4s ease forwards;
}
2. Slide in from top
@keyframes slideDown {
from { transform: translateY(-100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.notification {
animation: slideDown 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
3. Infinite spin (loading spinner)
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
4. Pulse / heartbeat
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.8; }
}
.live-badge {
animation: pulse 2s ease-in-out infinite;
}
5. Shake (error state)
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
.input--error {
animation: shake 0.4s ease;
}
6. Typewriter
@keyframes type {
from { width: 0; }
to { width: 100%; }
}
.typewriter {
overflow: hidden;
white-space: nowrap;
border-right: 2px solid;
font-family: monospace;
animation:
type 2s steps(30, end),
blink 0.75s step-end infinite;
width: fit-content;
}
@keyframes blink {
50% { border-color: transparent; }
}
7. Skeleton loading
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#e2e8f0 25%,
#f1f5f9 50%,
#e2e8f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
height: 1em;
}
8. Staggered list entrance
@keyframes fadeUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.list-item {
animation: fadeUp 0.4s ease forwards;
opacity: 0; /* start hidden */
}
/* Stagger via nth-child or CSS custom properties */
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 60ms; }
.list-item:nth-child(3) { animation-delay: 120ms; }
.list-item:nth-child(4) { animation-delay: 180ms; }
/* Or via JS: element.style.setProperty('--i', index) */
/* .list-item { animation-delay: calc(var(--i) * 60ms); } */
Performance — the golden rules
Only animate transform and opacity. These are composited by the GPU and don't trigger layout or paint.
/* GOOD — GPU composited, no layout recalculation */
.box { transition: transform 0.3s ease, opacity 0.3s ease; }
/* BAD — triggers layout (reflow) every frame */
.box { transition: width 0.3s ease, top 0.3s ease; }
Properties and their cost:
| Property | Triggers | Cost |
|---|---|---|
transform |
Composite | Very low ✓ |
opacity |
Composite | Very low ✓ |
color, background-color |
Paint | Medium |
width, height, top, left |
Layout + Paint | High ✗ |
box-shadow |
Paint | Medium |
will-change — use sparingly
/* Promotes element to its own layer before animation starts */
.animated-card {
will-change: transform;
}
/* Remove it after animation ends (saves memory) */
element.addEventListener('animationend', () => {
element.style.willChange = 'auto';
});
Respecting reduced motion
Always wrap decorative animations in a media query:
@keyframes slideIn {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.sidebar {
animation: slideIn 0.4s ease forwards;
}
/* Users who have "Reduce Motion" enabled in their OS settings */
@media (prefers-reduced-motion: reduce) {
.sidebar {
animation: none;
/* Or provide an instant/minimal alternative */
}
}
/* Or as a blanket rule */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Controlling animations with JavaScript
const el = document.querySelector('.spinner');
// Pause / resume
el.style.animationPlayState = 'paused';
el.style.animationPlayState = 'running';
// Restart animation (trick: remove, force reflow, add back)
el.classList.remove('animate');
void el.offsetWidth; // triggers reflow
el.classList.add('animate');
// Listen for completion
el.addEventListener('animationend', (e) => {
console.log(`${e.animationName} finished`);
});
// Stagger using custom properties
document.querySelectorAll('.item').forEach((item, i) => {
item.style.setProperty('--i', i);
});
// CSS: animation-delay: calc(var(--i) * 80ms);
6 common mistakes
1. Animating layout-triggering properties
width, height, top, left, margin trigger layout recalculation every frame. Use transform: scaleX() / translate() instead.
2. Forgetting animation-fill-mode: forwards
Without it, the element snaps back to its original style when the animation ends. Almost always set to forwards for entrance animations.
3. transition: all — too broad
It animates every property that changes, including display (which can't be animated) and causes unnecessary repaints. Be explicit: transition: transform 0.3s ease, opacity 0.3s ease.
4. No prefers-reduced-motion support
Vestibular disorders and epilepsy make motion dangerous for some users. Always add the @media (prefers-reduced-motion: reduce) guard on significant animations.
5. will-change on everything
will-change: transform promotes to a GPU layer — useful right before animation, harmful if left on permanently (consumes GPU memory). Apply it just before animation starts, remove it after.
6. Restarting animation by toggling class twice without reflow
If you remove and immediately re-add a class, the browser batches changes and the animation doesn't restart. Force a reflow with void el.offsetWidth between the remove and add.
6 FAQ
What's the difference between transitions and animations?
Transitions react to a state change (hover, class toggle) and go from A to B. Animations run independently via @keyframes, can loop, go through multiple steps, and start without a trigger.
Can I animate SVG properties?
Yes. fill, stroke, stroke-dashoffset (for draw-on effects), opacity, and transform all work. SVG animations are widely used for icon effects and path drawing.
How do I chain animations?
Use animation-delay equal to the previous animation's duration, or listen to animationend and add the next class via JS. For complex sequences, the Web Animations API (element.animate()) gives programmatic control.
Why does my animation flicker in Chrome?
Usually a compositing layer issue. Add transform: translateZ(0) or will-change: transform to force GPU compositing. Also check for half-pixel values in translate.
Can I use CSS animations with React?
Yes. Apply animation classes conditionally, use AnimatePresence from Framer Motion for unmount animations, or drive CSS animations with useEffect. The animation-play-state trick works well with useState.
What's cubic-bezier and where do I get values?
It's a mathematical curve defining acceleration. Use the Chrome DevTools cubic-bezier editor by clicking the curve icon next to transition in the Styles panel, or search "cubic bezier editor" online. Common presets: (0.25, 0.46, 0.45, 0.94) = ease-out soft, (0.34, 1.56, 0.64, 1) = spring overshoot.