CSS Animations, Keyframes & Performance

This section documents general HTML5 and CSS concepts — it is not tied to any specific framework or library. This content was generated with the assistance of AI. Verify it against current MDN documentation and browser-support tables (caniuse.com) before relying on it in production, since HTML/CSS features and browser support continue to evolve.

CSS transitions (covered on Transitions) animate an element smoothly between two states — typically triggered by a state change such as :hover or a class toggle. The animation property and the @keyframes rule go further: they let you define a self-contained, multi-step sequence that can run automatically, loop indefinitely, and move through as many intermediate states as you like, without needing an external trigger. This page covers the full animation shorthand and its sub-properties, the @keyframes syntax, several worked examples, the modern individual-transform and scroll-driven-animation additions, and closes with a dedicated section on how to keep animations performant.

The animation Property and Its Sub-Properties

To create a CSS animation sequence, you style the element you want to animate with the animation shorthand property or its individual sub-properties. These control the timing, duration, and other playback details of the animation — they do not by themselves define what the animation looks like. The visual appearance at each point in time is controlled separately, by the @keyframes rule (covered in the next section).

Property Values accepted Description

animation (shorthand)

Set of values (see below)

A shorthand for combining all the sub-properties below into a single declaration.

animation-delay

Seconds or milliseconds

Time before the animation starts.

animation-direction

normal, reverse, alternate, or alternate-reverse

Whether the animation plays forward, backward, or alternates between forward and backward on successive iterations.

animation-duration

Seconds or milliseconds

The length of time for one animation cycle.

animation-fill-mode

none, forwards, backwards, or both

How the animation applies styles to its target before it starts and/or after it finishes.

animation-iteration-count

infinite or a number (default: 1)

The number of times the animation should play before stopping.

animation-name

One or more @keyframes names

The name(s) of the @keyframes animation(s) to apply to the element.

animation-play-state

running or paused

Whether the animation is currently running or paused.

animation-timeline

none, auto, scroll(), view(), or a named timeline

The timeline used to drive the animation’s progress: the default document timeline, a scroll-driven timeline, a view-driven timeline, or a custom named timeline (see Scroll-Driven Animations below).

animation-timing-function

cubic-bezier(n,n,n,n), ease, ease-in, ease-out, ease-in-out, linear, step-start, or step-end

The rate of progression of the animation throughout each cycle’s duration.

Shorthand Order

When using the animation shorthand, its values are read in a specific order:

  1. Duration — time taken for one animation cycle.

  2. Timing function — rate of change over time.

  3. Delay — time before the animation starts.

  4. Iteration count — number of cycles.

  5. Direction — play direction (forward, backward, or alternating).

  6. Fill mode — styling applied before/after the animation.

  7. Play state — whether the animation is running or paused.

  8. Name — the @keyframes identifier to apply.

Default values apply to any sub-property that is omitted, except for duration and name, which are required for the animation to actually run. For example:

.box {
  animation: 3s ease-in 1s 2 reverse both paused slidein;
}

Reading that value left to right: 3s is the duration, ease-in the timing function, 1s the delay, 2 the iteration count, reverse the direction, both the fill mode, paused the play state, and slidein the @keyframes name.

The @keyframes Rule

While the animation property configures when and how many times an animation runs, the @keyframes rule dictates the visual behavior of the animated element at specific points throughout the sequence. Keyframes are positioned using percentages: 0% marks the start of the animation and 100% marks its end.

The start and end points can also be written as the keywords from and to, which are equivalent to 0% and 100% respectively, and are optional — if a point isn’t explicitly defined, the browser falls back to the element’s current computed value for that property.

A simple two-point keyframe animation, using from/to, that fades a blur() filter in over the sequence:

@keyframes blur-animation {
  from {
    filter: blur(0px); /* no blur at the beginning */
  }
  to {
    filter: blur(10px); /* blurred effect at the end */
  }
}

div {
  width: 200px;
  height: 200px;
  background-color: lightblue;
  animation: blur-animation 2s ease-in-out infinite alternate;
}

A @keyframes block is not limited to two points — percentage steps let you define as many intermediate states as the effect requires, as shown in the shake example further below.

Worked Examples

The examples below build up from a simple state change to a looping animation and a multi-step effect.

Hover-Menu Transition (recap)

Transitions covers this exercise in depth: animating a sidebar menu’s background-color on :hover via the transition shorthand, then layering in a font-weight and image-size change on the same hover trigger. The key performance lesson from that exercise carries over directly to animation-based effects too: name the specific properties you animate rather than relying on all, since all forces the browser to watch every animatable property for changes instead of only the ones that actually move.

ul li a {
  transition: font-weight 0.5s; /* only font-weight is watched */
}

ul li a img {
  transition: width 0.5s, height 0.5s; /* only width/height, not "all" */
}

Staggered Slide-in with @starting-style

CSS transitions do not run by default on an element’s very first style update, or when its display changes from none to another value — there is no "before" state to transition from. The @starting-style rule solves this by defining the styles an element should be considered to have before its first real update, so that the transition to its final state actually animates.

@starting-style can be used standalone or nested inside a ruleset:

/* standalone form */
@starting-style {
  ul li {
    opacity: 0;
    transform: translateX(-280px);
  }
}

/* final (default) state, with the properties above now transitioning */
ul li {
  transition:
    background-color 0.5s ease-in-out,
    opacity 0.5s ease-in-out,
    transform 0.7s ease-in-out;
  opacity: 1;
  transform: translateX(0px);
}

@starting-style is particularly useful for entry/exit transitions on elements that pop to the front (popovers, modal dialogs), and for elements toggling to/from display: none or being added to/removed from the DOM.

To turn this into a staggered slide-in — where each list item animates slightly after the previous one, instead of all moving simultaneously — add a per-item transition-delay using :nth-child():

ul li:nth-child(1) { transition-delay: 0.1s; }
ul li:nth-child(2) { transition-delay: 0.2s; }
ul li:nth-child(3) { transition-delay: 0.3s; }

Each list item then slides in from the left and fades in, cascading one after another rather than as a single block.

CSS Spinner

A loading spinner is one of the most common uses of a @keyframes animation, and, as covered in Optimize Animation Performance below, is generally cheaper than an equivalent .gif or JavaScript-driven alternative. The transform: rotate() property is used to spin the element a full circle, ending exactly where it started so the loop is seamless:

@keyframes load-spinner {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

img.spinner {
  width: 50px;
  height: 50px;
  animation: load-spinner 1s linear infinite;
}

Using linear as the timing function is important here — an ease function would cause the rotation to visibly speed up and slow down at the seams between loop iterations, breaking the illusion of constant spinning.

Shake Effect

A shake effect (for example, on a menu icon on :hover) is a good illustration of a keyframe animation with more than two steps. Instead of a single from/to pair, percentage steps alternate the transform back and forth to create the shaking motion:

@keyframes shake {
  0%   { transform: translateX(0); }
  25%  { transform: translateX(-5px) rotate(5deg); }
  50%  { transform: translateX(5px) rotate(-5deg); }
  75%  { transform: translateX(-5px) rotate(5deg); }
  100% { transform: translateX(0); }
}

ul li:hover a img {
  width: 24px;
  height: 24px;
  animation: shake 0.4s ease;
}

Because the animation is only applied on :hover and has a short, finite animation-duration (with the default animation-iteration-count: 1), it plays once per hover rather than looping.

Modern Additions

Modern CSS has continued to expand what animations can express and how efficiently they run, without requiring JavaScript.

Individual Transform Properties

CSS now supports translate, rotate, and scale as individual properties, alongside the combined transform property. Previously, every transform — however simple — had to be written through the single transform property:

@keyframes rotate-old {
  0%   { transform: rotate(0deg); }
  100% { transform: rotate(45deg); }
}

With individual transform properties, the same animation is simpler to write and reason about:

@keyframes rotate-new {
  0%   { rotate: 0deg; }
  100% { rotate: 45deg; }
}

Because translate, rotate, and scale are now separate properties, they can also be transitioned or animated independently of one another (and independently of a transform value applied through a different rule), which makes combining multiple simultaneous transform-like effects considerably easier to maintain.

transition-behavior: allow-discrete

Traditional CSS transitions and animations only work smoothly on properties with continuously interpolatable values, such as opacity or transform. Discrete properties — ones that flip between distinct values with nothing meaningful in between, such as display (none versus block) — could not be transitioned at all by default; they simply flipped instantly.

The transition-behavior property changes this: with transition-behavior: allow-discrete, a discrete property is allowed to participate in a transition, flipping to its new value at the midpoint (50%) of the transition’s duration instead of instantly at the start:

.element {
  transition: display 0.5s ease-in-out;
  transition-behavior: allow-discrete;
}

This is especially useful for elements such as popovers, native <dialog> elements, select menus, and custom components that need to animate in and out of existence (including toggling to/from display: none) rather than popping abruptly.

Scroll-Driven Animations

Scroll-driven animations tie an animation’s progress directly to a scroll position, instead of to elapsed time — common uses include parallax effects and reading-progress indicators. Historically, this required listening to scroll events on the main thread, which was prone to jank because the animation logic competed with everything else running on that thread.

The animation-timeline property, combined with the scroll() and view() functions, lets the browser drive these animations natively and off the main thread instead:

  • animation-timeline: scroll() ties the animation’s progress to the scroll position of a scrolling container.

  • animation-timeline: view() ties the animation’s progress to the animated element’s own position as it moves through the visible viewport (useful for "animate in as it scrolls into view" effects).

A scroll-driven progress bar, using scroll():

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

#progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 1em;
  background: red;
  transform-origin: 0 50%;
  animation: grow-progress auto linear;
  animation-timeline: scroll();
}

Note the auto value used in place of an explicit animation-duration here — with a scroll-driven timeline, the animation’s "duration" is the scrollable distance itself, not a fixed span of time.

Optimize Animation Performance

A smooth animation is one the browser can produce within its frame budget — roughly 16 ms per frame at 60 frames per second. The techniques below, ordered from the most broadly applicable to the most specific, keep animations inside that budget.

  • Name the specific properties you animate, instead of all. Both transition and keyframe animation`s default to watching every animatable property when none is named. Declaring only the properties that actually change (`transition: background-color 0.5s ease-in-out, opacity 0.5s ease-in-out rather than transition: all 0.5s) lets the browser skip the work of checking every other property on the element for changes.

  • Prefer keyframe animations over .gif files or JavaScript-driven animations for effects like loading spinners. A CSS @keyframes animation is typically far lighter than an equivalent animated .gif (which must decode and redraw every frame as image data) or a JavaScript animation loop (which runs on the main thread and must repeatedly recompute styles), depending on the application’s architecture.

  • Respect prefers-reduced-motion for accessibility. Repetitive motion, parallax, and flicker effects can cause real discomfort — including nausea — for users with vestibular disorders. The prefers-reduced-motion media query reflects an operating-system-level accessibility setting, with two values: reduce and no-preference. Gate non-essential animation behind it:

    .animation {
      position: absolute;
      top: 150px;
      left: 150px;
    }
    
    @media (prefers-reduced-motion: no-preference) {
      .animation {
        animation: move-around 1s 0.3s linear infinite both;
      }
    }
    
    @keyframes move-around {
      from { transform: translate(-50px, -50px); }
      to   { transform: translate(50px, 100px); }
    }

    With this pattern, a user who has enabled "Reduce motion" (macOS: System Settings > Accessibility > Display; Windows: Settings > Ease of Access > Display > Show animations) simply never receives the animation at all, rather than receiving a "toned down" version of it.

  • Prefer transform and opacity for the properties you actually animate. These two properties can typically be updated by the browser’s compositor on the GPU, without re-running layout or repaint on the main thread. Most other animatable properties (colors, filter, etc.) require at least a repaint, which is more expensive but still far cheaper than layout.

  • Avoid animating layout-triggering properties, such as width, height, top, left, or margin. Changing these forces the browser to recompute the position and size of the animated element and potentially every affected sibling (a "layout" or "reflow" pass) on every single frame of the animation, which is the most expensive category of rendering work and the most common cause of visibly janky animations. Where possible, express the same visual effect with transform instead — for example, transform: translate() instead of animating top/left, or transform: scale() instead of animating width/height.

  • Use will-change sparingly, as a hint, not a fix. Declaring will-change: transform (or will-change: opacity) tells the browser in advance that an element is about to be animated, letting it promote the element to its own GPU layer ahead of time rather than doing so reactively when the animation starts. Overusing will-change on many elements (or leaving it applied permanently instead of toggling it on shortly before the animation and removing it afterward) consumes GPU memory for layers that are not actually animating, which can hurt performance instead of helping it.

    .card {
      transition: transform 0.3s ease;
    }
    
    .card:hover {
      will-change: transform;
      transform: translateY(-4px);
    }