Overview

CSS owns motion. JavaScript animation libraries are for cases CSS cannot express. The rules are mechanical: animate transform and opacity, respect motion preferences, and pick transition or @keyframes by whether the change has a clear start state. The umbrella sits at css.

Use transition for state changes, @keyframes for sequences

transition interpolates between two values when a property changes. @keyframes defines a sequence of values to step through on its own.

  • Hover, focus, open, disabled, theme swap: transition.
  • Loading spinners, looping marquees, multi-step reveals: @keyframes.
.button {
  transition:
    background-color 150ms ease,
    transform 100ms ease;
}
.button:hover {
  background-color: var(--color-brand-hover);
  transform: translateY(-1px);
}
 
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}
.spinner {
  animation: spin 1s linear infinite;
}

If you reach for @keyframes to animate a hover, the change has two end states and a transition is shorter.

Animate transform and opacity only

Modern browsers composite transform and opacity on the GPU without re-running layout or paint. Every other property animation forces layout or paint per frame and tanks INP. See page-speed.

  • Move: transform: translate() not top / left.
  • Resize: transform: scale() not width / height.
  • Rotate: transform: rotate() not multiple properties.
  • Fade: opacity not visibility or display.
/* Slow: triggers layout on every frame. */
.toast {
  transition: top 200ms;
}
 
/* Fast: compositor-only. */
.toast {
  transition: transform 200ms;
}

If you need to animate a layout property (grid-template-rows: 0fr to 1fr), test on a mid-tier phone before shipping; some are now compositor-friendly, many still are not.

Respect prefers-reduced-motion

Some users get nauseated by motion. The OS exposes that preference; the page must honor it.

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

This is the WCAG 2.3.3 baseline. For motion that carries information (a progress bar, a reveal), provide a static alternative when reduced motion is requested rather than disabling the cue entirely. See accessibility.

Use will-change sparingly and remove it after

will-change promotes an element to its own compositor layer ahead of time. The hint is useful right before an animation starts; permanent will-change eats memory and can blur text.

/* Set just before the animation runs, remove on completion. */
.menu[data-state="opening"] {
  will-change: transform, opacity;
}
.menu[data-state="open"] {
  will-change: auto;
}

Do not write will-change: transform on every animated class as a habit. If the animation already runs smoothly, the property is harming you. Profile first.

Drive animation values with custom properties

CSS custom properties make animations themeable and reusable. Pair with css-custom-properties.

:root {
  --motion-fast: 150ms;
  --motion-base: 250ms;
  --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dialog {
  transition:
    opacity var(--motion-base) var(--ease-out),
    transform var(--motion-base) var(--ease-out);
}

To animate the custom property itself, register it with @property so the browser knows how to interpolate.

@property --tilt {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.card {
  transition: --tilt 300ms;
  transform: rotate(var(--tilt));
}
.card:hover {
  --tilt: 2deg;
}

Ship scroll-driven animations natively

animation-timeline ties an animation to a scroll position instead of a clock. Use it for reveal effects, scroll progress bars, and parallax without JavaScript scroll listeners.

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
  }
}
 
.section {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

view() ties the animation to the element’s own intersection with the viewport. scroll() ties it to an ancestor’s scroll progress. Chrome, Edge, and Safari (since Safari 26, September 2025) ship this unflagged; Firefox still sits behind the layout.css.scroll-driven-animations.enabled flag in stable as of mid-2026. Provide a non-animated fallback for browsers that ignore the timeline.

Common pitfalls

  • Animating height: auto. The compute pass cannot interpolate auto. Use grid-template-rows: 0fr to 1fr, or animate a max-height to a known value, or measure with a ResizeObserver and set a pixel value before transitioning.
  • display: none blocks animation. A display: none element does not run transitions when shown. Use the @starting-style rule plus transition-behavior: allow-discrete for entry animations, or toggle a state class instead.
  • Layout thrash from non-compositor properties. A page that animates top, left, or width will jank on mobile. Convert to transform.
  • Stuck will-change. Permanent will-change: transform on every interactive element eats GPU memory and can blur subpixel text. Remove after the animation ends.
  • Ignoring reduced motion. A site without a reduced-motion stylesheet fails WCAG 2.3.3; see accessibility.