CSS Animations: Creating Smooth Transitions
You add a hover effect to a card, it looks fine on your laptop, and then someone opens the page on a mid-range Android phone and the whole list stutters while scrolling. Or a dropdown "animates" by snapping open with no easing at all, because the transition was set on the wrong property. Most animation bugs I run into aren't about missing features. They come from animating the wrong thing, or animating it the wrong way.
This post covers CSS transitions and CSS keyframe animations: when to reach for each, how timing functions change the feel, which properties are cheap to animate, and how to respect users who've asked for less motion.
Motion earns its place when it does a job:
- Guide attention: point users at the thing that just changed
- Provide feedback: confirm a click, a save, a state change
- Create continuity: show where a panel came from so the change doesn't feel like a jump cut
- Add a bit of character: fine in small doses, as long as it never slows anyone down
CSS Transitions vs. CSS Animations
These two get mixed up a lot. The short version: a transition reacts to a state change, an animation runs its own script.
Transitions handle simple A→B changes, while animations support complex multi-step sequences
| Feature | Transitions | Animations |
|---|---|---|
| Complexity | Simple (2 states) | Complex (unlimited keyframes) |
| Trigger | Requires state change | Auto or triggered |
| Looping | No | Yes (infinite) |
| Use Case | Hover effects, toggles | Loading spinners, attention grabbers |
CSS Transitions
When a property value changes (because of :hover, a class toggle, whatever), the browser normally jumps straight to the new value. A transition tells it to interpolate over some duration instead. That's the whole idea, and it covers most of the motion on a typical site.
The Transition Properties
transition-property
Which CSS property to animate
all | opacity | transform
transition-duration
How long the animation takes
0.3s | 300ms | 1s
transition-timing-function
The acceleration curve
ease | linear | ease-in-out
transition-delay
Wait before starting
0s | 0.2s | 500ms
.button {
background-color: #a855f7;
transform: scale(1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
/* Shorthand: property duration timing-function delay */
transition: all 0.3s ease;
/* Or be specific (better for performance): */
transition:
transform 0.3s ease,
background-color 0.3s ease,
box-shadow 0.3s ease;
}
.button:hover {
background-color: #9333ea;
transform: scale(1.05);
box-shadow: 0 10px 20px rgba(168, 85, 247, 0.3);
}
Interactive Demo: Hover Transition
Hover over the box to see the transition in action
I'd avoid transition: all in production. List the properties you actually mean to animate. With all, any property that changes later (a theme switch, a media query, a width set by JavaScript) suddenly animates too, which is both surprising and slower.
Two things that trip people up constantly. First, display: none to display: block won't transition; the element just appears. Toggle opacity and visibility instead (or look into transition-behavior: allow-discrete in newer browsers). Second, you can't transition height to auto in most browsers today. The usual workarounds are animating max-height to a value that's comfortably bigger than the content, or using a grid trick with grid-template-rows: 0fr to 1fr.
Understanding Timing Functions
The timing function sets the acceleration curve: how the motion speeds up and slows down. Same duration, same distance, completely different feel. As a rule of thumb, ease-out works well for things entering the screen, ease-in for things leaving, and linear mostly for spinners and progress bars.
cubic-bezier(0.25, 0.1, 0.25, 1)cubic-bezier(0, 0, 1, 1)cubic-bezier(0.42, 0, 1, 1)cubic-bezier(0, 0, 0.58, 1)cubic-bezier(0.42, 0, 0.58, 1)cubic-bezier(0.68, -0.55, 0.265, 1.55)Hover over each bar to see the different timing functions
Bezier curves define how progress maps to output over time
CSS Keyframe Animations
Once you need more than two states, want something to play on page load, or need it to loop, transitions run out of road. That's where @keyframes comes in.
Defining Keyframes
Keyframes define the waypoints your animation passes through. You can use percentages or the keywords from (0%) and to (100%).
/* Simple two-state animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Multi-step animation with percentages */
@keyframes bounce {
0%, 100% {
transform: translateY(0);
animation-timing-function: ease-out;
}
50% {
transform: translateY(-30px);
animation-timing-function: ease-in;
}
}
/* Applying the animation */
.element {
animation: bounce 1s infinite;
}
Animation Properties
.animated-element {
/* Individual properties */
animation-name: bounce; /* which @keyframes to use */
animation-duration: 1s; /* how long one cycle takes */
animation-timing-function: ease; /* acceleration curve */
animation-delay: 0.5s; /* wait before starting */
animation-iteration-count: 3; /* number | infinite */
animation-direction: alternate; /* normal | reverse | alternate */
animation-fill-mode: forwards; /* none | forwards | backwards | both */
animation-play-state: running; /* running | paused */
/* Shorthand */
animation: bounce 1s ease 0.5s 3 alternate forwards;
}
Live Animation Examples
Bounce
Pulse
Spin
Shake
View Keyframe Code for All Examples
/* Bounce Animation */
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-30px); }
}
/* Pulse Animation */
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.7;
}
}
/* Spin Animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Shake Animation */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
CSS Transform: Where Most Motion Should Live
If you only remember one thing from this post, make it this: animate transform instead of position or size. Browsers can usually handle transforms on the compositor (often on the GPU) without recalculating layout, so the main thread stays free and the motion stays smooth, especially on phones.
Transform functions can be chained in one declaration (order matters)
/* Individual transforms */
transform: translateX(100px); /* Move horizontally */
transform: translateY(-50px); /* Move vertically */
transform: translate(100px, 50px); /* Move both axes */
transform: scale(1.5); /* Scale uniformly */
transform: scale(1.5, 0.8); /* Scale X and Y separately */
transform: rotate(45deg); /* Rotate clockwise */
transform: skew(10deg, 5deg); /* Skew X and Y */
/* Combine multiple transforms (order matters!) */
transform: translateY(-10px) scale(1.1) rotate(5deg);
/* 3D transforms */
transform: perspective(1000px) rotateY(45deg);
transform: translateZ(100px); /* Move toward viewer */
/* Transform origin (pivot point) */
transform-origin: center center; /* Default */
transform-origin: top left; /* Rotate from corner */
transform-origin: 50% 100%; /* Bottom center */
These two are the ones browsers can reliably animate on the compositor at 60fps:
transform: translate, scale, rotate, skewopacity: fades
Animating width, height, top, left, margin, or padding forces layout on every frame. On a small element you may get away with it; on a big list it's where the jank comes from.
Real-World Animation Patterns
These four show up in almost every project I touch. Copy them, then tune durations to taste.
1. Fade In on Scroll
.fade-in-up {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.fade-in-up.visible {
opacity: 1;
transform: translateY(0);
}
/* Staggered children */
.fade-in-up:nth-child(1) { transition-delay: 0.1s; }
.fade-in-up:nth-child(2) { transition-delay: 0.2s; }
.fade-in-up:nth-child(3) { transition-delay: 0.3s; }
2. Loading Spinner
.spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: #a855f7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
3. Button Hover Effect
.btn {
position: relative;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s ease;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(168, 85, 247, 0.3);
}
.btn:hover::before {
left: 100%;
}
4. Skeleton Loading Screen
.skeleton {
background: linear-gradient(
90deg,
#1a1a1a 25%,
#2a2a2a 50%,
#1a1a1a 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
Accessibility Considerations
For people with vestibular disorders, big sliding and zooming effects can cause real dizziness or nausea. Operating systems let users turn on "reduce motion," and CSS can read that setting. Honor it:
/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
::before,
::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Or provide alternative, subtle animations */
@media (prefers-reduced-motion: reduce) {
.fancy-animation {
animation: none;
opacity: 1; /* Show immediately instead of fade */
}
}
- No flashing: never flash content more than 3 times per second (seizure risk, and a WCAG requirement)
- Provide controls: anything that loops for more than 5 seconds should be pausable
- Keep it subtle: large movements are the ones that trigger vertigo
- Test with reduced motion on: make sure nothing stays hidden at
opacity: 0when the animation is skipped
Debugging & Performance
Use DevTools Animation Panel
Chrome DevTools has an Animations panel (More Tools → Animations). It records animations as they run and lets you replay them at 25% or 10% speed, which is the easiest way to spot a bad easing curve.
Check Paint Flashing
Turn on "Paint flashing" in the DevTools Rendering tab. Areas that repaint flash green. If a transform animation lights up the whole card on every frame, something else on it (a shadow, a filter) is being repainted too.
Monitor Frame Rate
Record in the Performance tab with CPU throttling set to 4x or 6x, since your dev machine hides problems. At 60fps you have about 16.7ms per frame. Long purple "Layout" blocks during an animation usually mean you're animating a layout property.
Use will-change Sparingly
will-change tells the browser to prepare a separate layer ahead of time. Each layer costs GPU memory, so putting it on dozens of elements can make things worse. Apply it only to elements that are about to animate.
/* Use will-change to optimize (sparingly!) */
.animated-element {
will-change: transform, opacity;
}
/* Remove after animation completes */
.animated-element.done {
will-change: auto;
}
When I'd Use CSS Animation (and When I Wouldn't)
For hover states, focus rings, dropdowns, toasts, spinners and skeleton loaders, plain CSS is what I reach for first. It's declarative, it doesn't block the main thread when you stick to transform and opacity, and prefers-reduced-motion handles accessibility in a few lines.
I wouldn't force it in a few cases:
- Animations that depend on runtime values, like dragging, physics, or following the pointer. You end up writing JavaScript anyway, so use the Web Animations API or a library built for it.
- Long choreographed sequences where step three has to wait for step two to finish. Chaining
animation-delayvalues by hand gets fragile fast. - Animating elements as they're removed from the DOM. CSS can't animate something that's already gone, so frameworks need a helper (React Transition Group, Vue's
<Transition>) or the View Transitions API. - Motion that doesn't do a job. If an animation only exists because it looked fun in the design file, it's usually the first thing users start to find annoying.
Keep durations short (most UI transitions feel right somewhere around 150 to 300ms) and test on a slow phone before you ship.