A CSS animation has two parts. A @keyframes rule describes how property values change over the course of one cycle, and the animation properties on an element say which keyframes to run, for how long, with what easing, and what to do before and after. Most animation bugs come from the second part, so this guide spends more time there.

How @keyframes and animation fit together

Keyframes are written as percentages of one cycle. from is an alias for 0% and to is an alias for 100%. The element picks them up through animation-name, which must match the @keyframes name exactly; a typo fails silently, with no error in the console.

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

.toast {
  animation: fade-in-up 300ms ease-out both;
}

A few rules about the @keyframes block itself are worth knowing:

  • If you leave out 0% or 100%, the browser uses the element's current styles for that end. @keyframes pulse { 50% { opacity: 0.4; } } animates from the element's own opacity, down to 0.4, and back.
  • If two @keyframes rules share a name, the last one parsed wins. They do not merge, which matters when a component library and your stylesheet both define something called fade-in.
  • Declarations marked !important inside a keyframe are ignored.
  • Properties that cannot be animated are ignored inside keyframes.

The animation longhands and their defaults

The animation shorthand sets eight properties. Knowing their initial values explains most surprises, because anything you leave out of the shorthand is reset to its default.

  • animation-name: none. The @keyframes to run.
  • animation-duration: 0s. Length of one cycle. With a duration of 0s the animation still fires its start and end events, but nothing visible happens.
  • animation-timing-function: ease. How progress is distributed between keyframes.
  • animation-delay: 0s. Wait before the first cycle. Negative values are allowed.
  • animation-iteration-count: 1. A number (fractions such as 0.5 work) or infinite.
  • animation-direction: normal. Also reverse, alternate and alternate-reverse.
  • animation-fill-mode: none. Whether keyframe values apply before the start and after the end.
  • animation-play-state: running. Set it to paused to freeze the animation where it is.

In the shorthand, order mostly does not matter, with two exceptions. The first time value is always the duration and the second is the delay, so animation: 2s 500ms means a 2 second animation after a 500ms wait. And because a value that could belong to another property is assigned to that property first, a keyframes name that collides with a keyword (such as ease, infinite or forwards) will be misread. MDN recommends writing the name last. Names like fade-in-up never collide, so in practice name-first and name-last both work.

css
/* Shorthand */
.card {
  animation: fade-in-up 400ms cubic-bezier(0.215, 0.61, 0.355, 1) 100ms 1 normal both;
}

/* The same thing as longhands */
.card {
  animation-name: fade-in-up;
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);
  animation-delay: 100ms;
  animation-iteration-count: 1;
  animation-direction: normal;
  animation-fill-mode: both;
}

Timing functions: keywords, cubic-bezier() and steps()

The keywords linear, ease, ease-in, ease-out and ease-in-out are all shorthands for cubic-bezier() curves. A cubic-bezier(x1, y1, x2, y2) curve takes two control points. The x values must stay between 0 and 1 because they represent time; the y values can go outside that range, which is how overshoot and anticipation are made. cubic-bezier(0.175, 0.885, 0.32, 1.275) passes its end value and settles back, which suits a small badge popping in and looks wrong on a large panel.

The most misunderstood detail: the timing function applies to each interval between keyframes, not to the whole animation. With keyframes at 0%, 50% and 100% and animation-timing-function: ease, the element eases in and out twice. To shape a multi-step animation, set animation-timing-function inside individual keyframes. The value in a keyframe applies to the segment that starts at that keyframe.

css
@keyframes heartbeat {
  from {
    transform: scale(1);
    animation-timing-function: ease-out;
  }
  10% {
    transform: scale(0.91);
    animation-timing-function: ease-in;
  }
  17% {
    transform: scale(0.98);
    animation-timing-function: ease-out;
  }
  33% {
    transform: scale(0.87);
    animation-timing-function: ease-in;
  }
  45% {
    transform: scale(1);
    animation-timing-function: ease-out;
  }
}

steps(n) jumps between n discrete values instead of interpolating. The second argument says where the jumps happen: jump-end (the default, also written end) holds each value until the end of its step, jump-start jumps at the beginning, jump-none includes both end values, and jump-both adds a jump at each end. Steps are the right tool for sprite sheets and for a blinking text cursor.

css
/* An 8-frame sprite sheet, 64px per frame, laid out horizontally */
.walker {
  width: 64px;
  height: 64px;
  background: url("/walk-cycle.png") 0 0 / 512px 64px no-repeat;
  animation: walk 800ms steps(8) infinite;
}

@keyframes walk {
  to {
    background-position: -512px 0;
  }
}

/* A cursor that is either fully on or fully off */
.cursor {
  animation: blink 1s steps(1) infinite;
}

@keyframes blink {
  50% {
    opacity: 0;
  }
}

With steps(8) and the default jump-end, the sprite shows offsets 0, -64px and so on down to -448px. The -512px value is never displayed, because the cycle restarts at the moment it would be reached. That is why the end value is the full width of the sheet, not the position of the last frame.

Delay, iteration count and direction

A positive delay waits before the first cycle. A negative delay starts the animation as if it had already been running for that long. Negative delays are useful for staggering looping elements: give three loading dots the same animation with delays of 0s, -0.2s and -0.4s and they are out of phase from the first frame, instead of waiting in turn.

css
.dot {
  animation: bounce 1.2s ease-in-out infinite;
}
.dot:nth-child(2) { animation-delay: -0.2s; }
.dot:nth-child(3) { animation-delay: -0.4s; }

@keyframes bounce {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-6px); }
}

animation-direction: alternate plays odd cycles forward and even cycles backward. When a cycle runs backward, its timing function runs backward too, so ease-in on the way out becomes ease-out on the way back. alternate with an iteration count of infinite gives a smooth back-and-forth from only two keyframes, which is less code than writing 0%, 50% and 100% by hand.

Fill mode: before the start and after the end

By default an animation only affects the element while it is running. Before it starts (during a delay) and after it finishes, the element shows its normal styles. This causes two very common bugs.

  • The snap-back. A slide that moves an element 100px up finishes and the element jumps back to where it started. animation-fill-mode: forwards keeps the final keyframe's values after the animation ends.
  • The flash. A fade-in with a 300ms delay shows the element at full opacity for 300ms, then hides it, then fades it in. animation-fill-mode: backwards applies the first keyframe during the delay.

both applies the two behaviors together and is the safest choice for one-off entrance animations. Which keyframe forwards keeps depends on direction and iteration count: with alternate and an even count, the last cycle runs backward, so the element ends on the 0% values.

Be careful with forwards on elements you later want to change. Animated values sit above normal declarations in the cascade, so a :hover rule that sets transform will not apply while a filled animation is holding transform. If the end state should be the element's real state, put those values in the element's normal styles and let the animation run from an offset to that state with fill-mode backwards or both.

Pausing with animation-play-state

animation-play-state: paused freezes an animation at its current point, and running continues it from there. It is the simplest way to stop a looping animation on hover or focus, or to give people a pause control for an animation that runs on its own.

css
.marquee-track {
  animation: scroll-x 30s linear infinite;
}

.marquee:hover .marquee-track,
.marquee:focus-within .marquee-track,
.marquee[data-paused] .marquee-track {
  animation-play-state: paused;
}

Running several animations on one element

Every animation property accepts a comma-separated list. The first value of each longhand belongs to the first animation, and so on. If a longhand list is shorter than the list of names, it repeats.

The catch is that two animations on the same property do not combine. If one animation moves transform: translateY() and another changes transform: rotate(), the one listed later wins for the whole transform. There are two fixes. The individual transform properties translate, rotate and scale are separate properties, so they animate independently. Or set animation-composition: add so values are added to the underlying value instead of replacing it.

css
/* Floats and spins at the same time */
.badge {
  animation:
    float 3s ease-in-out infinite alternate,
    spin 8s linear infinite;
}

@keyframes float {
  to { translate: 0 -6px; }
}

@keyframes spin {
  to { rotate: 1turn; }
}

The same problem appears with an element's existing transform. A modal centered with transform: translate(-50%, -50%) that runs a keyframes animation on transform: scale() loses its centering for the length of the animation, because the keyframes replace the whole transform. Animate scale instead of transform, or include the translate in every keyframe.

Animate transform and opacity

Browsers can usually animate transform (including translate, rotate and scale) and opacity on the compositor, without recalculating layout or repainting each frame. Properties such as width, height, top, left and margin change layout, so every frame can cause the page to reflow. box-shadow, filter: blur() and background colors on large areas are cheaper than layout changes but still repaint.

  • Use transform: scale() rather than animating width and height. For a card that grows on hover, scale it and accept that text scales too, or animate a background pseudo-element behind the content.
  • Use translate rather than top or left to move things.
  • To animate a shadow, put the larger shadow on a ::after pseudo-element and animate that element's opacity.
  • Use will-change sparingly, and only after you have seen a performance problem. Each promoted layer costs memory, and applying it to many elements can make things slower.
css
.card {
  position: relative;
  transition: transform 200ms ease-out;
}

.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 18px 40px -12px rgb(15 23 42 / 0.35);
  opacity: 0;
  transition: opacity 200ms ease-out;
  pointer-events: none;
}

.card:hover {
  transform: translateY(-2px);
}

.card:hover::after {
  opacity: 1;
}

Restarting an animation

An animation runs when animation-name is first applied. Adding a class the element already has does nothing, and removing and re-adding it in the same frame also does nothing, because the browser never sees the intermediate state. There are three reliable ways to replay it.

js
// 1. Remove the class, force a style flush, add it back
function replay(el, className) {
  el.classList.remove(className);
  void el.offsetWidth; // reading layout makes the browser apply the removal
  el.classList.add(className);
}

// 2. Rewind the running CSS animation with the Web Animations API
function rewind(el) {
  for (const animation of el.getAnimations()) {
    animation.currentTime = 0;
    animation.play();
  }
}

// 3. React: change the key so the element is remounted
// <div key={replayCount} className="animate-fade-in-up" />

The Web Animations API version avoids forcing layout, and it works for animations that have already finished. To run code when an animation ends, listen for the animationend event, which reports the keyframes name in event.animationName.

Common mistakes

  • A misspelled or undefined animation-name. Nothing happens and nothing is logged. Check the Animations panel in Chrome or Firefox DevTools to see whether the animation is attached at all.
  • Exit animations on elements that are hidden immediately. Setting display: none cancels a running animation, so a fade-out never shows. Keep the element rendered, run the animation, and hide it on animationend. (Removing display: none starts the animation again, which is why entrance animations work without extra code.)
  • Infinite attention animations. Shakes, pulses and bounces that never stop compete with the content. Run them once in response to something, such as a failed form submission.
  • Ignoring the reduced motion setting. At minimum, turn off large movement when the visitor has asked for less motion.
css
@media (prefers-reduced-motion: reduce) {
  .toast {
    /* Keep the fade, drop the movement */
    animation-name: fade-in;
  }
}

@keyframes fade-in {
  from { opacity: 0; }
}

Turning keyframes into Tailwind utilities

In Tailwind CSS v4, an --animate-* variable in @theme creates an animate-* utility, and @keyframes can be written inside the same @theme block. Tailwind also provides motion-safe: and motion-reduce: variants, which wrap a utility in the matching prefers-reduced-motion media query.

css
@import "tailwindcss";

@theme {
  --animate-fade-in-up: fade-in-up 300ms ease-out both;

  @keyframes fade-in-up {
    from {
      opacity: 0;
      transform: translateY(12px);
    }
  }
}
html
<div class="motion-safe:animate-fade-in-up">Saved</div>

Building one with the GradientDeck animation generator

The animation generator starts from a preset and lets you set each longhand. Presets are grouped as Basic (scale, rotate and slide from different origins), Attention (vibrate, shake, pulse, jello), Fade (in and out, from each side) and Background (Ken Burns and background pan). Timing offers the five native keywords plus cubic-bezier versions of the Penner easing curves, including the Back curves that overshoot. Duration and delay switch between s and ms, iteration count is 1 or infinite, and direction and fill mode cover every keyword. The preview replays whenever you change a setting, and you can pause or restart it on a box, circle, button, card and other sample elements.

Copying gives you the @keyframes rule and a class with the full shorthand, in the order name, duration, timing function, delay, iteration count, direction, fill mode. Two things to check after pasting:

  • The default fill mode is none. For slides and fades that should stay in their final position, change it to forwards or both before copying.
  • The background pan presets animate background-position. The preview gives the element background-size: 200% 200% so there is something to pan across; the copied class does not include it, so add a background larger than the element yourself.

Below the generator, the curated collection has more presets with their own default settings. Each card copies the same way, and if you have reduced motion turned on, the cards stay still until you press their restart button.

References

MDN: animation

MDN: @keyframes

MDN: easing functions

web.dev: How to create high-performance CSS animations

Tailwind CSS: animation