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. Both ship in Chrome and Edge today; Safari and Firefox are in progress as of 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.