March 26, 20266 min read

CSS Animations: Transitions, Keyframes, and Not Janking the Browser

Practical guide to CSS transitions, @keyframes animations, and transforms. How to build hover effects, loading spinners, and page transitions that actually run at 60fps.

css animations transitions web-development performance
Ad 336x280

CSS has two animation systems and they solve different problems. Transitions handle the simple case: "smoothly change from A to B when something happens." Keyframe animations handle everything else: "play this multi-step animation on a loop, with pauses, reversals, and custom timing." Knowing which to reach for saves you a lot of overengineering.

Transitions: The Easy One

A transition watches a CSS property and smoothly interpolates when that property changes (usually on hover, focus, or a class toggle).

.button {
  background: #2563eb;
  transform: scale(1);
  transition: background 200ms ease, transform 150ms ease;
}

.button:hover {
background: #1d4ed8;
transform: scale(1.05);
}

The transition shorthand takes four values:

  • property -- which CSS property to animate (all works but is lazy; be specific)
  • duration -- how long the transition takes (200ms, 0.3s)
  • timing-function -- the acceleration curve (ease, ease-in-out, linear, or cubic-bezier())
  • delay -- wait before starting (0s by default)
You can comma-separate multiple transitions with different durations per property. This is handy when you want color to change faster than size.

A few things to know:

  • Not every CSS property can be transitioned. display, visibility (sort of), and anything that isn't a continuous value won't work. color, opacity, transform, background, box-shadow -- those all transition smoothly.
  • Put the transition on the base state, not the :hover state. Otherwise the transition only plays on hover-in, not hover-out.
  • transition: all 300ms ease is tempting but it transitions everything that changes, including properties you didn't intend. Spell out the properties.

@keyframes: The Powerful One

When you need more than "go from A to B" -- multiple steps, looping, or animations that play on load -- you need @keyframes.

@keyframes spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

.loader {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

The animation shorthand:

animation: name duration timing-function delay iteration-count direction fill-mode;

The properties you'll actually set:

  • animation-name -- references the @keyframes block
  • animation-duration -- how long one cycle takes
  • animation-timing-function -- linear for constant speed, ease-in-out for natural motion
  • animation-iteration-count -- a number, or infinite
  • animation-direction -- normal, reverse, alternate (ping-pong back and forth)
  • animation-fill-mode -- forwards keeps the final state after the animation ends. Without it, the element snaps back.
Multi-step keyframes use percentages:
@keyframes pulse {
  0%   { opacity: 1; transform: scale(1); }
  50%  { opacity: 0.6; transform: scale(0.95); }
  100% { opacity: 1; transform: scale(1); }
}

.skeleton {
animation: pulse 1.5s ease-in-out infinite;
}

Transform: The GPU-Accelerated Workhorse

transform doesn't animate anything by itself -- it just changes an element's visual representation. But it's the backbone of performant animations because the browser can offload transforms to the GPU.
.card:hover {
  transform: translateY(-4px) scale(1.02);
}

The transforms you'll use constantly:

  • translate(x, y) -- moves the element. translateX(), translateY(), translateZ() for individual axes
  • scale(factor) -- resizes. scale(1.1) is 10% bigger
  • rotate(angle) -- spins. rotate(45deg), rotate(0.5turn)
You can chain them: transform: translateY(-8px) rotate(2deg) scale(1.05). They apply right to left, which matters if you're combining rotation with translation.

Performance: The 60fps Rule

The browser renders at 60 frames per second (ideally). That's ~16.6ms per frame. If your animation triggers layout recalculation or repainting, it'll blow past that budget and stutter.

The golden rule: only animate transform and opacity. These two properties can be composited on the GPU without triggering layout or paint. Everything else -- width, height, top, left, margin, padding, border, box-shadow -- forces the browser to recalculate layout, repaint pixels, or both.

Bad (triggers layout every frame):

.slide-in {
  transition: left 300ms ease;
  left: -100%;
}
.slide-in.active {
  left: 0;
}

Good (GPU-composited):

.slide-in {
  transition: transform 300ms ease;
  transform: translateX(-100%);
}
.slide-in.active {
  transform: translateX(0);
}

Same visual result, vastly different performance on mobile devices.

will-change

will-change tells the browser to prepare a GPU layer for an element before the animation starts:
.card {
  will-change: transform;
  transition: transform 200ms ease;
}

Use it sparingly. Every will-change creates a new compositor layer that consumes GPU memory. Slapping will-change: transform on fifty cards is worse than the jank you're trying to avoid. Apply it to the few elements that actually need buttery-smooth animation -- hero sections, modals, carousels. Remove it when the animation is done if possible (via JavaScript).

Practical Examples

Fade-in on scroll (CSS part)

.fade-up {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 500ms ease, transform 500ms ease;
}

.fade-up.visible {
opacity: 1;
transform: translateY(0);
}

Toggle the .visible class via IntersectionObserver in JS. Simple, performant, no animation library needed.

Page transition overlay

@keyframes slideOut {
  from { transform: translateX(0); }
  to   { transform: translateX(100%); }
}

.page-transition {
position: fixed;
inset: 0;
background: #1e293b;
animation: slideOut 400ms ease-in forwards;
}

Staggered list items

.list-item {
  opacity: 0;
  transform: translateY(12px);
  animation: fadeIn 300ms ease forwards;
}

.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 80ms; }
.list-item:nth-child(3) { animation-delay: 160ms; }
.list-item:nth-child(4) { animation-delay: 240ms; }

@keyframes fadeIn {
to { opacity: 1; transform: translateY(0); }
}

For more than a few items, generate the delays with a CSS custom property and calc(), or use nth-child() formulas in a preprocessor.

Respecting User Preferences

Some people get motion sickness from animations. The prefers-reduced-motion media query lets you tone things down:

@media (prefers-reduced-motion: reduce) {
  , ::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

This doesn't remove animations entirely -- it just makes them instant, so state changes still happen but nothing moves.

Build Something

Reading about easing curves and keyframe percentages only gets you so far. The real learning happens when you try to build a loading spinner and it wobbles wrong, or a hover effect feels sluggish until you switch from margin-top to translateY. CodeUp has CSS challenges where you can experiment with animations in the browser and see the results immediately. Start with hover effects, then build a spinner, then try a full page transition.

Ad 728x90