web-animation-css-animations · git:20260906.d80c3e7 · 2026-09-06 · sha256 f4a4a078a8fb32a7

web-animation-css-animations git:20260906.d80c3e7A

Immutable. This exact content is served forever at /api/v1/blob/f4a4a078a8fb32a7.

---
name: web-animation-css-animations
description: CSS animation patterns - transitions, keyframes, scroll-driven timelines, @property, compositor-friendly properties, prefers-reduced-motion
---

# CSS Animation Patterns

> **Quick Guide:** Transitions carry state changes (hover, focus, a toggled attribute); `@keyframes`
> carries motion that loops, auto-plays, or has more than two steps; `animation-timeline` carries
> scroll- and viewport-linked progress. Confining animation to `transform` and `opacity` keeps the
> frames on the compositor thread, and every animation gets a `prefers-reduced-motion` branch.

**Detailed Resources:**

- [examples/core.md](examples/core.md) — token system, interactive states, entrance, spinner, toast, reduced-motion
- [examples/transitions.md](examples/transitions.md) — multi-property transitions, staggered delays, accordions, colour, links
- [examples/keyframes.md](examples/keyframes.md) — scroll-driven timelines, `@property` gradients, typewriter, stagger, clip-path morphs
- [reference.md](reference.md) — easing catalogue, property cost table, duration guidance, browser support

---

## Which path applies

- **The motion is a state change** — `:hover`, `:focus-visible`, a data attribute, a toggled class —
  then a `transition` on the base rule is the whole mechanism; follow
  [examples/transitions.md](examples/transitions.md).
- **The motion loops, auto-plays on mount, or passes through more than two states** — then it needs
  `@keyframes` and an `animation` shorthand; follow [examples/keyframes.md](examples/keyframes.md).
- **The motion tracks scroll position or viewport entry** — then the driver is
  `animation-timeline: scroll()` or `view()` rather than time, and the keyframes describe progress
  from 0 to 1; follow [examples/keyframes.md](examples/keyframes.md).

---

<critical_requirements>

## Before writing CSS animation code

**Animate `transform` and `opacity`.** Both are composited, so the frames run off the main thread and
survive a busy tab; `width`, `top` and `margin` re-run layout on every frame instead.

**Give every animation a `prefers-reduced-motion` branch.** The preference is a vestibular safety
setting rather than an off switch — an opacity fade at a shorter duration usually satisfies it while
keeping the state change legible.

**Use `ease-out` on enter and `ease-in` on exit.** An element arriving decelerates into place and one
leaving accelerates away; `linear` reads as mechanical for anything but continuous rotation.

**Scope `will-change` to the interaction that needs it.** Each declaration holds a compositing layer
for as long as the rule applies, so a blanket selector holds one per element on the page at once.

</critical_requirements>

---

**Auto-detection:** @keyframes, transition-property, transition-duration, animation-timeline,
scroll-timeline, view-timeline, animation-range, animation-fill-mode, prefers-reduced-motion,
will-change, cubic-bezier, linear(), @property, steps(), transform-origin

**Applies to:**

- State-change motion driven by a pseudo-class, a data attribute or a toggled class
- Autonomous motion — spinners, pulses, shimmer, attention cues
- Scroll-linked and viewport-entry progress
- Entrance and exit motion whose trigger is a class or attribute the page already sets

**Handled elsewhere:**

- Playback control at runtime — pause, reverse, seek, or read progress. A CSS declaration exposes no
  handle; the Web Animations API is where one comes from, either `element.animate()` or
  `element.getAnimations()` over what CSS already declared
- Motion whose velocity carries across an interruption, such as a spring picked up mid-gesture
- Pointer-tracking drag, where the animated value is the pointer position itself
- Compositing an outgoing and an incoming view together across a navigation or view swap

---

<decision_framework>

## Easing selection

```
Element entering        -> ease-out (fast start, slow settle)
Element exiting         -> ease-in (slow start, fast departure)
Symmetric motion        -> ease-in-out
Continuous rotation     -> linear
Playful, overshooting   -> cubic-bezier with a control point past 1
Anything else           -> ease-out
```

`ease`, the browser default, is generic enough that two adjacent animations using it read as
unrelated; name the curve instead.

## What CSS expresses

- **Scroll and viewport progress** — `animation-timeline: scroll()` or `view()`, with
  `animation-range` deciding where progress starts and ends
- **Sequencing across elements** — `animation-delay` computed from an `--index` custom property, with
  `backwards` fill so the pre-animation state holds during the delay
- **Values computed at runtime** — write them into a custom property; the animation itself stays
  declarative and reads the property each frame
- **Overshoot and arbitrary curves** — a `cubic-bezier` past the 0–1 range, or `linear()` with a
  point list for a curve no cubic can express

</decision_framework>

---

<patterns>

## Core patterns

### Pattern 1: Animation Token System

Durations, easings and travel distances defined once as custom properties, so motion stays consistent
across components and is retunable in one place.

```css
:root {
  --duration-fast: 150ms;
  --duration-normal: 250ms;
  --ease-out: cubic-bezier(0, 0, 0.2, 1); /* enter */
  --ease-in: cubic-bezier(0.4, 0, 1, 1); /* exit */
  --ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275); /* overshoot */
  --lift-md: -4px;
}
```

Full code: [examples/core.md](examples/core.md)

---

### Pattern 2: Compositor-Only Transitions

Name each property being transitioned, and express movement and size as `transform` so no frame
triggers layout.

```css
.card {
  transition:
    transform var(--duration-fast) var(--ease-out),
    opacity var(--duration-fast) var(--ease-out);
}
.card:hover {
  transform: translateY(var(--lift-md)) scale(1.02);
}
```

`translate()` replaces `top`/`left`, `scale()` replaces `width`/`height`, and a pseudo-element whose
`opacity` animates replaces an animated `box-shadow`.

Full code: [examples/core.md](examples/core.md)

---

### Pattern 3: Prefers-Reduced-Motion

Two shapes. Progressive enhancement makes the still state the base and opts motion in, so an
animation added later cannot escape the check:

```css
.element {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: no-preference) {
  .element {
    animation: fade-slide-in var(--duration-normal) var(--ease-out);
  }
}
```

Graceful degradation animates by default and overrides under `reduce` — the right shape when the
reduced form is a shorter fade rather than nothing:

```css
@media (prefers-reduced-motion: reduce) {
  .notification {
    animation: fade-in calc(var(--notification-duration) * 0.5) var(--ease-out);
  }
}
```

Reduced motion does not mean no animation. Opacity is generally safe; what it replaces is spatial
travel, scale and rotation.

Full code: [examples/core.md](examples/core.md)

---

### Pattern 4: @keyframes

For motion that loops, auto-plays on mount, or passes through more than two states.

```css
@keyframes fade-slide-in {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.modal {
  animation: fade-slide-in var(--modal-enter-duration) var(--ease-out) forwards;
}
```

`forwards` holds the final state after the run; `backwards` shows the initial state during
`animation-delay`.

Full code: [examples/core.md](examples/core.md) and
[examples/keyframes.md](examples/keyframes.md)

---

### Pattern 5: Will-Change Scoping

`will-change` promotes the element to its own compositing layer, which costs GPU memory
proportional to the element's painted area. Declare it on the rule that is about to animate.

```css
.card:hover {
  will-change: transform;
}
```

Full code: [examples/core.md](examples/core.md)

---

### Pattern 6: Scroll-Driven Animations

`animation-timeline` drives keyframes from scroll progress instead of elapsed time, with no
scroll listener and no per-frame JavaScript.

```css
.progress-bar {
  animation: grow-width linear;
  animation-timeline: scroll();
}

@keyframes grow-width {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}
```

`scroll()` tracks a scroll container's position; `view()` tracks the element's own passage through
the viewport, with `animation-range` bounding it.

**Browser support:** Chrome/Edge 115+, Safari 26+, Firefox behind a flag.

Full code: [examples/keyframes.md](examples/keyframes.md)

---

### Pattern 7: @property for Custom Property Animation

Registering a custom property gives it a type, which is what makes it interpolable — gradient angles
and colour stops animate only once registered.

```css
@property --gradient-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

@keyframes rotate-gradient {
  to {
    --gradient-angle: 360deg;
  }
}
```

**Browser support:** Chrome/Edge 85+, Safari 16.4+, Firefox 128+.

Full code: [examples/keyframes.md](examples/keyframes.md)

</patterns>

---

<red_flags>

## Red flags

**Breaks at runtime:**

- `transition: all` — picks up every property a later edit adds, including layout-triggering ones —
  name each transitioned property explicitly
- Animating `width`, `height`, `top`, `left`, `margin` or `padding` — re-runs layout every frame and
  drops frames as soon as the main thread is busy — animate `transform` and leave layout still
- Animating `box-shadow` — repaints the element and its shadow each frame — animate the `opacity` of
  a pseudo-element that carries the shadow
- `will-change` on a permanent or broad selector — holds one compositing layer per matched element,
  and on mobile enough layers exhaust GPU memory and kill the tab — declare it on the interaction
  rule only
- An animation with no `prefers-reduced-motion` branch — full-travel motion reaches users who have
  asked their OS for none — add the branch when the animation is written, not afterwards
- An enter animation without `forwards` — the element snaps back to its pre-animation state on the
  final frame — add the fill mode

**Surprising behaviour:**

- `transform` on an ancestor creates a containing block, so a `position: fixed` descendant anchors to
  that ancestor rather than to the viewport
- `will-change` creates a stacking context, changing how `z-index` resolves against siblings
- Without `animation-fill-mode: backwards`, a delayed animation renders its final state during the
  delay instead of its first frame
- `display: none` cannot be animated — use `opacity` with `visibility`, or `grid-template-rows`
  animating `0fr` to `1fr`
- SVG path drawing animates `stroke-dasharray` and `stroke-dashoffset`; `transform` moves the path
  rather than drawing it
- A `scroll()` timeline needs a scrollable ancestor — an `overflow: hidden` parent yields no progress
- Animations do not run in print, so the pre-animation state has to be legible on paper
- Durations past roughly 1s read as sluggish rather than deliberate

</red_flags>