---
title: "CSS: Animations"
slug: "css-animations"
category: "frontend"
tags: ["frontend", "css", "animation", "transitions", "performance", "accessibility"]
status: "stable"
last_updated: 2026-08-14
summary: "transition vs @keyframes, animate transform and opacity, respect prefers-reduced-motion, use will-change sparingly, and ship scroll-driven animations natively."
related:
  [
    "[[frontend/css]]",
    "[[frontend/css-custom-properties]]",
    "[[frontend/accessibility]]",
    "[[frontend/react]]",
    "[[seo/page-speed]]",
  ]
---

## 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 [[frontend/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`.

```css
.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 [[seo/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`.

```css
/* 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.

```css
@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 [[frontend/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.

```css
/* 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 [[frontend/css-custom-properties]].

```css
: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.

```css
@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.

```css
@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 [[frontend/accessibility]].

## Related

- [[frontend/css]]
- [[frontend/css-custom-properties]]
- [[frontend/accessibility]]
- [[frontend/react]]
- [[seo/page-speed]]
