Web Development

CSS3 Transitions and Animations: A Practical Guide

CSS3 Transitions and Animations: A Practical Guide

CSS animations transform static web pages into interactive, responsive interfaces. Transitions handle smooth changes between two states — a button changing color on hover, a sidebar sliding into view. Keyframe animations create multi-step sequences — loading spinners, scroll-triggered reveals, complex entrance effects. Together they cover the vast majority of motion design needs without touching JavaScript.

This guide covers both systems in depth, from basic hover effects through production-ready animation patterns, performance optimization, and the View Transitions API that shipped in browsers in 2024.

CSS Transitions: The Foundation

A transition tells the browser to animate a property change smoothly over a specified duration instead of applying it instantly. The syntax defines which property to animate, how long the animation takes, the timing curve, and an optional delay:

.button {
  background-color: #c2724e;
  color: white;
  padding: 0.75rem 1.5rem;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  transition: background-color 0.3s ease, transform 0.2s ease;
}

.button:hover {
  background-color: #a85d3a;
  transform: translateY(-2px);
}

.button:active {
  transform: translateY(0);
  transition-duration: 0.1s;
}

The transition applies only to the properties you list. Other properties still change instantly, which is often what you want — you probably do not want cursor changes or display changes to animate.

Transition Property Reference

.element {
  /* Individual properties */
  transition-property: background-color, transform, opacity;
  transition-duration: 0.3s, 0.2s, 0.4s;
  transition-timing-function: ease, ease-out, linear;
  transition-delay: 0s, 0s, 0.1s;

  /* Shorthand — most common approach */
  transition: background-color 0.3s ease,
              transform 0.2s ease-out,
              opacity 0.4s linear 0.1s;

  /* Transition all animatable properties (use sparingly) */
  transition: all 0.3s ease;
}

Avoid transition: all in production code. It transitions every property that changes, including ones you did not intend to animate, and it prevents the browser from optimizing specific property transitions.

Timing Functions Explained

Timing functions control the acceleration curve of an animation. The difference between a professional animation and an amateur one often comes down to the timing function choice:

/* Built-in keywords */
transition-timing-function: ease;        /* slow-fast-slow (default) */
transition-timing-function: linear;      /* constant speed */
transition-timing-function: ease-in;     /* starts slow, accelerates */
transition-timing-function: ease-out;    /* starts fast, decelerates */
transition-timing-function: ease-in-out; /* slow start and end */

/* Custom cubic-bezier curves */
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);  /* Material Design standard */
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); /* smooth deceleration */
transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55); /* overshoot bounce */

/* Step functions for frame-by-frame animation */
transition-timing-function: steps(4, end); /* 4 discrete steps */

For most UI transitions, ease-out or cubic-bezier(0.4, 0, 0.2, 1) feels natural. Elements that appear should decelerate into position (ease-out). Elements that disappear should accelerate away (ease-in). Use Chrome DevTools’ cubic-bezier editor to visualize and tweak curves interactively. Most modern code editors also provide CSS animation previews and timing function visualization through extensions.

CSS Keyframe Animations

While transitions animate between two states (initial and changed), keyframe animations define multiple stages and run independently of state changes. You define the animation with @keyframes and apply it with the animation property:

@keyframes fadeSlideIn {
  0% {
    opacity: 0;
    transform: translateY(20px);
  }
  100% {
    opacity: 1;
    transform: translateY(0);
  }
}

.card {
  animation: fadeSlideIn 0.6s ease-out forwards;
}

Multi-Step Animations

@keyframes pulse {
  0%, 100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.05);
    opacity: 0.8;
  }
}

@keyframes typing {
  0% { width: 0; }
  50% { width: 100%; }
  50.1% { border-right-color: transparent; }
  100% { width: 100%; border-right-color: transparent; }
}

@keyframes shimmer {
  0% { background-position: -200% 0; }
  100% { background-position: 200% 0; }
}

/* Skeleton loading placeholder */
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}

Animation Property Reference

.element {
  /* Individual properties */
  animation-name: fadeSlideIn;
  animation-duration: 0.6s;
  animation-timing-function: ease-out;
  animation-delay: 0.2s;
  animation-iteration-count: 1;       /* or 'infinite' */
  animation-direction: normal;         /* or 'reverse', 'alternate' */
  animation-fill-mode: forwards;       /* keeps final state after animation */
  animation-play-state: running;       /* or 'paused' */

  /* Shorthand */
  animation: fadeSlideIn 0.6s ease-out 0.2s 1 normal forwards running;
}

The fill-mode property matters more than it first appears. Without forwards, elements snap back to their pre-animation state when the animation ends. Without backwards, elements flash their pre-animation state before the delay period ends. Use both to apply the start state during delay and the end state after completion.

CSS Transforms

Transforms are the workhorse of CSS animation. They modify an element’s visual rendering without affecting layout, which is why they animate smoothly at 60fps on most devices:

/* Individual transforms */
transform: translateX(100px);
transform: translateY(-50%);
transform: translate(100px, -50px);
transform: scale(1.2);
transform: scale(0.8, 1.2);       /* scaleX, scaleY */
transform: rotate(45deg);
transform: skew(10deg, 5deg);

/* Combined transforms (applied right to left) */
transform: translate(-50%, -50%) scale(1.1) rotate(5deg);

/* 3D transforms */
transform: perspective(800px) rotateY(15deg);
transform: translateZ(50px);
transform: rotate3d(1, 1, 0, 45deg);

/* Modern individual transform properties (Chrome 104+, Safari 14.1+) */
translate: 100px -50px;
scale: 1.2;
rotate: 45deg;

The newer individual transform properties (translate, scale, rotate) can be transitioned independently, which was impossible with the combined transform property. This makes complex multi-property animations much simpler to write and maintain.

Performance: What to Animate and What to Avoid

Not all CSS properties animate equally. The browser rendering pipeline has three stages — layout, paint, and composite — and properties that trigger earlier stages cost more to animate:

  • Composite only (cheap): transform, opacity — animated by the GPU compositor, no layout or paint recalculation. Target 60fps even on mobile
  • Paint (moderate): background-color, box-shadow, border-color — repaint the element but do not recalculate layout
  • Layout (expensive): width, height, padding, margin, top, left, font-size — trigger full layout recalculation that cascades to surrounding elements
/* Bad — animating layout properties */
.drawer {
  width: 0;
  transition: width 0.3s ease;
}
.drawer.open {
  width: 300px;
}

/* Good — animating transform instead */
.drawer {
  transform: translateX(-100%);
  transition: transform 0.3s ease;
}
.drawer.open {
  transform: translateX(0);
}

/* Bad — animating top/left */
.tooltip {
  top: 100%;
  transition: top 0.2s ease;
}
.tooltip.visible {
  top: -10px;
}

/* Good — animating transform */
.tooltip {
  transform: translateY(100%);
  opacity: 0;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.tooltip.visible {
  transform: translateY(-10px);
  opacity: 1;
}

The will-change Property

/* Hint to the browser that this property will animate */
.animated-element {
  will-change: transform, opacity;
}

/* Remove when animation is done to free GPU memory */
.animated-element.idle {
  will-change: auto;
}

will-change promotes the element to its own compositor layer, which speeds up animations but consumes GPU memory. Apply it to elements that will animate frequently (sidebar toggles, modal overlays) and remove it from elements that animate once (page load transitions). Overusing will-change on many elements simultaneously can actually degrade performance by exhausting GPU memory.

Practical Animation Patterns

Staggered List Animations

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(15px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.list-item {
  opacity: 0;
  animation: fadeInUp 0.4s ease-out forwards;
}

/* Stagger using custom properties */
.list-item:nth-child(1) { animation-delay: 0.05s; }
.list-item:nth-child(2) { animation-delay: 0.1s; }
.list-item:nth-child(3) { animation-delay: 0.15s; }
.list-item:nth-child(4) { animation-delay: 0.2s; }
.list-item:nth-child(5) { animation-delay: 0.25s; }

/* Or use a CSS custom property set by JavaScript for dynamic lists */
.list-item {
  animation-delay: calc(var(--index) * 0.05s);
}

Smooth Accordion

.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;
}

This pattern uses CSS Grid’s ability to animate the fr unit, creating a smooth height animation without knowing the content height in advance. It avoids the common JavaScript workaround of reading scrollHeight and setting explicit pixel heights.

Loading Spinner

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

.spinner {
  width: 2rem;
  height: 2rem;
  border: 3px solid #e7e5e4;
  border-top-color: #c2724e;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

Respecting User Preferences

Some users experience motion sickness or discomfort from animations. The prefers-reduced-motion media query lets you disable or reduce animations for these users:

/* Reduce or remove animations for users who prefer reduced motion */
@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;
  }
}

/* Alternative: provide reduced (not eliminated) motion */
@media (prefers-reduced-motion: reduce) {
  .card {
    /* Replace slide animation with a simple fade */
    animation: fadeIn 0.3s ease-out forwards;
  }
}

This is not optional. Accessibility requires that motion-sensitive users can use your interface without discomfort. The prefers-reduced-motion query is supported in all modern browsers and should be included in every project that uses animations. Combined with responsive design best practices, motion preferences ensure your interface works for everyone.

The View Transitions API

The View Transitions API, which reached stable browser support in Chrome and Safari in 2024, enables smooth animated transitions between different page states or even different pages. It captures a snapshot of the current state, applies the DOM changes, and animates between the two:

// Basic view transition
document.startViewTransition(() => {
  // Update the DOM
  updateContent(newData);
});

// Customize the transition animation with CSS
::view-transition-old(root) {
  animation: fadeOut 0.3s ease-out;
}

::view-transition-new(root) {
  animation: fadeIn 0.3s ease-in;
}

Named View Transitions

/* Assign transition names to specific elements */
.product-image {
  view-transition-name: product-hero;
}

.product-title {
  view-transition-name: product-title;
}

/* Animate named elements independently */
::view-transition-old(product-hero) {
  animation: scaleOut 0.4s ease-in;
}

::view-transition-new(product-hero) {
  animation: scaleIn 0.4s ease-out;
}

::view-transition-old(product-title) {
  animation: slideOut 0.3s ease-in;
}

::view-transition-new(product-title) {
  animation: slideIn 0.3s ease-out 0.1s;
}

View Transitions enable the kind of smooth page-to-page animations that previously required single-page application frameworks. For multi-page applications built with modern frameworks, this API provides native-app-quality transitions with minimal code.

Animation and Design Systems

In production codebases, animations should be standardized through design tokens. Define timing, easing, and duration values as CSS custom properties so every animation in the project feels consistent:

:root {
  /* Duration tokens */
  --duration-fast: 0.15s;
  --duration-normal: 0.3s;
  --duration-slow: 0.5s;

  /* Easing tokens */
  --ease-default: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
  --ease-out: cubic-bezier(0, 0, 0.2, 1);
  --ease-bounce: cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

.button {
  transition: background-color var(--duration-fast) var(--ease-default),
              transform var(--duration-fast) var(--ease-default);
}

.modal {
  transition: opacity var(--duration-normal) var(--ease-out),
              transform var(--duration-normal) var(--ease-out);
}

This approach ties animation design to modern CSS custom property patterns and ensures that motion feels unified across the entire application. When a design system specifies motion tokens, implementing them in CSS becomes straightforward.

Frequently Asked Questions

When should I use CSS animations vs JavaScript animations?

Use CSS for state-based transitions (hover, focus, active), simple entrance and exit animations, and looping effects (spinners, pulsing indicators). Use JavaScript (via the Web Animations API or libraries like GSAP) for animations that depend on user input (scroll position, drag), animations that need to be sequenced with complex timing, or physics-based motion like spring animations. For page transitions, the View Transitions API bridges both worlds.

Why do my animations look choppy on mobile devices?

The most common cause is animating layout-triggering properties (width, height, top, left) instead of compositor properties (transform, opacity). Switch to transforms wherever possible. Also check for box-shadow animations, which trigger expensive repaints on mobile GPUs. If the animation involves many elements, ensure you are not over-applying will-change and exhausting GPU memory.

How do I trigger CSS animations on scroll?

Use the Intersection Observer API to add a class when an element enters the viewport, then tie your animation to that class. This is more performant than listening to the scroll event directly. CSS Scroll-Driven Animations (available in Chrome since 2024) provide a pure-CSS approach using animation-timeline: view() that requires no JavaScript at all.

Should I still use vendor prefixes for CSS animations?

No. All modern browsers support unprefixed transition, animation, transform, and @keyframes. Vendor prefixes (-webkit-, -moz-) were necessary before 2015 but are now redundant. If you need to support very old browsers, use Autoprefixer in your build pipeline to add prefixes automatically rather than maintaining them by hand.