CSS gives you two tools for motion: transitions and @keyframes animations. Most developers know both exist but reach for whichever one they learned first — usually transitions — and then wonder why the loading spinner won't loop or why the notification snaps back to its original position. The decision isn't complicated once you understand what each one is actually for. Transitions are a reaction: something changes state, the browser smooths the change. @keyframes animations are self-contained scripts: they run on their own, loop if you want, and don't need a state trigger. This guide covers both, the properties that matter most, and the performance details that separate smooth 60fps animations from janky ones. If you're writing a lot of CSS, the CSS Formatter is worth bookmarking — it'll clean up any messy shorthand quickly.
CSS Transitions — The Simple Case
A transition tells the browser: "when this property changes, animate the change over time instead of jumping." That's it. The most common use is hover effects — color changes, opacity fades, subtle scale transforms. The MDN transitions guide covers the full spec, but the four properties you care about are transition-property, transition-duration, transition-timing-function, and transition-delay.
/* The shorthand: property duration easing delay */
.btn-primary {
background-color: #4f46e5;
color: #fff;
padding: 0.625rem 1.25rem;
border-radius: 6px;
border: none;
cursor: pointer;
transition: background-color 200ms ease, transform 150ms ease;
}
.btn-primary:hover {
background-color: #4338ca;
transform: translateY(-1px);
}
.btn-primary:active {
transform: translateY(0);
}
/* Opacity fade — a card coming into view */
.notification-card {
opacity: 0;
transition: opacity 300ms ease-in-out;
}
.notification-card.is-visible {
opacity: 1;
}You can list multiple transitions separated by commas, each with its own duration and easing — that's what the button example above does. Each property animates independently.
transition: all. It's tempting as a catch-all, but it means every property change on that element gets animated — including ones you didn't intend. It also makes the browser do more work on each frame. List the specific properties you want to transition. The MDN transition-property docs explain which properties are animatable.transition-timing-function — Easing Explained
The timing function controls the acceleration curve of the animation — whether it starts fast and slows down, moves at a constant pace, or bounces through a custom curve. The built-in keywords cover most situations.
ease— starts slow, accelerates, then slows near the end. The default. Good for most UI motion.linear— constant speed throughout. Good for spinners and progress bars, bad for most other things — looks mechanical.ease-in— starts slow, ends fast. Feels like something picking up speed. Good for elements leaving the screen.ease-out— starts fast, ends slow. Feels natural for elements entering the screen (like a notification sliding in).ease-in-out— slow on both ends. Looks polished for elements that start and stop in view.cubic-bezier(x1, y1, x2, y2)— define your own curve. Tools like cubic-bezier.com let you build and preview custom curves visually.
/* Custom spring-like easing with cubic-bezier */
.drawer {
transform: translateX(-100%);
transition: transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.drawer.is-open {
transform: translateX(0);
}
/* The cubic-bezier values above overshoot slightly (y > 1)
which gives a satisfying spring effect on open */@keyframes — When Transitions Aren't Enough
Transitions have a hard limit: they need two states and a trigger. If you want something to loop indefinitely, run on page load without any interaction, or animate through more than two steps, you need @keyframes. The MDN @keyframes reference covers the full syntax. You define the keyframes separately, then attach them to an element with the animation property.
/* Define the animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Attach it to an element */
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 700ms linear infinite;
}
/* Multi-step — more than two stops */
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
.badge-live {
animation: pulse-ring 1.5s ease-in-out infinite;
}The animation Shorthand — All Eight Properties
The animation shorthand packs eight properties into one declaration. You don't need all of them every time, but knowing what each controls saves you from digging through docs when something doesn't behave.
/*
animation: name | duration | timing-function | delay | iteration-count | direction | fill-mode | play-state
*/
.toast {
animation: slide-in-right 350ms ease-out 0s 1 normal forwards running;
}
/* More commonly written as just the values you need: */
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
/* Breaking it out to individual properties for clarity: */
.confetti-piece {
animation-name: float-down;
animation-duration: 2s;
animation-timing-function: ease-in;
animation-delay: calc(var(--i) * 150ms); /* staggered via CSS custom property */
animation-iteration-count: 1;
animation-direction: normal;
animation-fill-mode: forwards;
animation-play-state: running;
}
/* Pausing an animation via JavaScript: toggle a class */
.spinner.is-paused {
animation-play-state: paused;
}animation-fill-mode — Fixing the Snap-Back Problem
This is the property that trips everyone up at some point. By default, when an animation ends, the element snaps back to its pre-animation styles — as if the animation never happened. If you animate a toast notification sliding in from the right, it'll snap back off-screen the moment the animation finishes. animation-fill-mode: forwards fixes this by keeping the element at its final keyframe state once the animation ends.
@keyframes slide-in-right {
from {
transform: translateX(110%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Without forwards: toast slides in, then SNAPS back off-screen */
.toast-bad {
animation: slide-in-right 350ms ease-out;
}
/* With forwards: toast slides in and STAYS in position */
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
/*
fill-mode values:
- none (default) — no styles applied before or after
- forwards — hold the final keyframe after the animation ends
- backwards — apply the first keyframe during the delay period
- both — forwards + backwards combined
*/Real Examples — Skeleton Loader, Spinner, Toast, Pulsing Badge
Here are four patterns you'll use constantly. Each one demonstrates a specific combination of @keyframes and animation properties.
/* --- 1. Skeleton loading shimmer --- */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite linear;
border-radius: 4px;
}
.skeleton-title { height: 20px; width: 60%; margin-bottom: 12px; }
.skeleton-text { height: 14px; width: 100%; margin-bottom: 8px; }
.skeleton-text:last-child { width: 80%; }/* --- 2. Spinning loader --- */
@keyframes spin {
to { transform: rotate(360deg); }
}
.loader-spinner {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid rgba(79, 70, 229, 0.2);
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 600ms linear infinite;
}/* --- 3. Slide-in notification toast --- */
@keyframes toast-enter {
from {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toast-exit {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
}
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
padding: 0.875rem 1.25rem;
background: #1f2937;
color: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
animation: toast-enter 350ms ease-out forwards;
}
.toast.is-dismissing {
animation: toast-exit 300ms ease-in forwards;
}/* --- 4. Pulsing notification badge --- */
@keyframes badge-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
}
.notification-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 999px;
animation: badge-pulse 2s ease-in-out infinite;
}Performance — GPU-Composited vs Layout-Triggering Properties
Not all CSS properties are equal when it comes to animation performance. The browser rendering pipeline has three stages that matter here: layout (calculating element sizes and positions), paint (filling in pixels), and composite (combining layers on the GPU). Animating a property that triggers layout means the browser recalculates the entire document geometry on every frame — that's expensive, and it's what causes jank. web.dev's animations performance guide covers this in depth.
- Safe to animate (GPU-composited):
transform(translate, scale, rotate) andopacity. These run entirely on the GPU compositor thread — they never trigger layout or paint. - Causes paint (avoid if possible):
color,background-color,border-color,box-shadow. These skip layout but trigger a repaint on every frame. Short transitions (under 300ms) are usually fine. - Causes layout (never animate):
width,height,top,left,margin,padding. Every frame triggers a full layout recalculation — guaranteed jank on complex pages.
transform and opacity only. If you want to move something, use translate not left/top. If you want to resize something, use scale not width/height. CSS Triggers is a reference that lists exactly which rendering stages each property hits./* Bad — animating width triggers layout on every frame */
.progress-bar-bad {
transition: width 300ms ease;
}
/* Good — use scaleX transform instead */
.progress-bar {
transform-origin: left center;
transition: transform 300ms ease;
}
/* In JS, set: progressBar.style.transform = 'scaleX(0.75)' for 75% */
/* Bad — animating top/left (positions element with layout) */
.tooltip-bad {
position: absolute;
top: 0;
transition: top 200ms ease;
}
/* Good — use translateY instead */
.tooltip {
position: absolute;
top: 0;
transform: translateY(0);
transition: transform 200ms ease;
}will-change — Use It Sparingly
will-change is a hint to the browser that a specific property is about to be animated, so it should promote the element to its own compositor layer in advance. This can eliminate the brief flash of jank you sometimes see at the very start of an animation on low-end hardware. But it has a real cost: each promoted layer consumes GPU memory. If you put will-change: transform on every animated element in your app, you'll degrade performance on most devices — the opposite of what you wanted.
/* Right way — add it just before animation starts, remove after */
.modal-overlay {
/* Don't set will-change here by default */
}
/* Add via JavaScript only when user triggers modal open */
/* overlay.style.willChange = 'opacity'; */
/* overlay.addEventListener('transitionend', () => { */
/* overlay.style.willChange = 'auto'; */
/* }); */
/* Acceptable in CSS for elements that animate frequently
(e.g., a persistent floating action button on scroll) */
.fab {
will-change: transform; /* OK — this element genuinely animates on scroll */
transition: transform 200ms ease;
}
/* Don't do this — wastes GPU memory with zero benefit */
.card {
will-change: transform; /* BAD — card only animates on hover, not constantly */
}prefers-reduced-motion — Accessibility You Can't Skip
A significant portion of users have vestibular disorders or other conditions where motion triggers discomfort or nausea. The prefers-reduced-motion media query lets you respect the system-level "reduce motion" setting. The WCAG 2.1 guideline 2.3.3 covers this requirement, and MDN's prefers-reduced-motion reference shows browser support (it's universal now). The pattern is simple: wrap your animations in the standard media query, and provide a no-motion fallback inside the reduced-motion one.
/* Define animations normally for users who are fine with motion */
@keyframes slide-in-right {
from { transform: translateX(110%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast {
animation: slide-in-right 350ms ease-out forwards;
}
.spinner {
animation: spin 600ms linear infinite;
}
/* Override for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.toast {
/* No sliding — just appear */
animation: none;
opacity: 1;
transform: none;
}
.spinner {
/* Slow it way down or stop it entirely */
animation-duration: 4s;
}
/* Nuclear option — disable ALL animations site-wide */
/* Use this only if you haven't audited each animation individually */
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}The "nuclear option" at the end of that snippet is a common pattern for existing codebases that haven't audited every animation. It's better than nothing, but auditing each animation individually gives you more control — some animations convey state (a progress bar, a loading spinner) and should be kept, just slowed down.
Transitions vs @keyframes — The Decision Guide
If you're ever unsure which to reach for, ask two questions about the animation you're building:
- Is it triggered by a state change? (hover, focus, class toggle, checkbox) → Use a transition. That's exactly what transitions are designed for.
- Does it loop indefinitely? → Use @keyframes with
animation-iteration-count: infinite. - Does it have more than two steps? (not just start → end, but start → middle → end or more) → Use @keyframes.
- Does it need to run on page load, without any user interaction? → Use @keyframes.
- Does the element need to stay at its end state after finishing? → Use @keyframes with
animation-fill-mode: forwards.
Wrapping Up
Transitions handle state-driven motion. @keyframes handle everything else. Always animate transform and opacity for performance — leave width, height, top, and left out of it. Use animation-fill-mode: forwards whenever you need the element to hold its final state. Add the prefers-reduced-motion override before you ship anything — it's a ten-line addition that matters a lot to real users. For more on the compositing model and what triggers layout, web.dev's rendering performance docs are the most practical resource available. Once you've written the CSS, run it through the CSS Formatter to keep the shorthand readable, or through the CSS Minifier to strip whitespace before shipping to production.