CSS animation keyframes, property by property

A CSS animation is two pieces: an @keyframes rule that says what changes, and an animation property that says how long, how often and when. Change one setting at a time below and watch the box.

A CSS animation needs an @keyframes rule with a name, and an animation property on the element that uses that name with a duration.

The keyframes list the styles at points in time. The animation properties set the length, the curve, the delay, the repeats and what happens at the end.

@keyframes move {
  from { transform: translateX(0); }
  to   { transform: translateX(200px); }
}

.box {
  animation: move 1.5s ease-in-out infinite alternate;
}

Try every setting below. The shorthand line under the controls updates as you change them, and Pause toggles animation-play-state.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animation property playground</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .track { position: relative; height: 64px; border-radius: 10px; background: #fff; border: 1px dashed #cfd4dc; }
  /* the runner is as wide as the track minus the box, so translateX(100%) lands at the end */
  .runner { position: absolute; left: 8px; top: 8px; width: calc(100% - 64px); }
  .box { width: 48px; height: 48px; border-radius: 10px; background: #2563eb; }

  @keyframes move {
    from { transform: translateX(0) scale(.6); opacity: .5; }
    to   { transform: translateX(100%) scale(1); opacity: 1; }
  }

  .controls { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 12px; margin: 12px 0; }
  label { font-size: 13px; display: flex; flex-direction: column; gap: 3px; }
  select, input { font: inherit; }
  .buttons { display: flex; gap: 8px; flex-wrap: wrap; }
  button { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid #c8ced8; background: #fff; cursor: pointer; }
  button.main { background: #2563eb; color: #fff; border-color: #2563eb; }
  pre { margin: 12px 0 6px; padding: 10px 12px; border-radius: 8px; background: #1d2330; color: #e5e7eb;
        font: 13px/1.4 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-word; }
  #log { font-size: 13px; color: #4b5563; margin: 0; }
</style>
</head>
<body>
<div class="track"><div class="runner" id="runner"><div class="box"></div></div></div>

<div class="controls">
  <label>duration <span><input id="duration" type="range" min="0.2" max="4" step="0.1" value="1.5"> <b id="durOut"></b></span></label>
  <label>timing-function
    <select id="timing">
      <option>ease</option><option>linear</option><option>ease-in</option><option>ease-out</option>
      <option>ease-in-out</option><option>steps(5)</option><option>cubic-bezier(.3, 1.5, .6, 1)</option>
    </select></label>
  <label>delay <span><input id="delay" type="range" min="-1" max="2" step="0.1" value="0.5"> <b id="delOut"></b></span></label>
  <label>iteration-count
    <select id="count"><option>1</option><option>2</option><option>2.5</option><option>infinite</option></select></label>
  <label>direction
    <select id="direction"><option>normal</option><option>reverse</option><option>alternate</option><option>alternate-reverse</option></select></label>
  <label>fill-mode
    <select id="fill"><option>none</option><option>forwards</option><option>backwards</option><option>both</option></select></label>
</div>

<div class="buttons">
  <button class="main" id="play">Play</button>
  <button id="pause">Pause</button>
</div>
<pre id="code"></pre>
<p id="log">Press Play.</p>

<script>
  const runner = document.getElementById('runner');
  const $ = (id) => document.getElementById(id);

  function shorthand() {
    // order: name duration timing-function delay iteration-count direction fill-mode
    return `move ${$('duration').value}s ${$('timing').value} ${$('delay').value}s ` +
           `${$('count').value} ${$('direction').value} ${$('fill').value}`;
  }

  function show() {
    $('durOut').textContent = $('duration').value + 's';
    $('delOut').textContent = $('delay').value + 's';
    $('code').textContent = `.box {\n  animation: ${shorthand()};\n}`;
  }

  $('play').addEventListener('click', () => {
    runner.style.animation = 'none';
    void runner.offsetWidth;            // force a reflow so the animation starts over
    runner.style.animation = shorthand();
    $('pause').textContent = 'Pause';
    $('log').textContent = 'Running...';
  });

  $('pause').addEventListener('click', () => {
    const paused = runner.style.animationPlayState === 'paused';
    runner.style.animationPlayState = paused ? 'running' : 'paused';
    $('pause').textContent = paused ? 'Pause' : 'Resume';
  });

  runner.addEventListener('animationstart', () => { $('log').textContent = 'animationstart fired.'; });
  runner.addEventListener('animationiteration', () => { $('log').textContent = 'animationiteration fired.'; });
  runner.addEventListener('animationend', () => {
    $('log').textContent = 'animationend fired. With fill-mode none or backwards the box snaps back to the start.';
  });

  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', show));
  show();
</script>
</body>
</html>
One @keyframes rule and seven animation settings. Change one, press Play, and read the shorthand it produces.

@keyframes: from, to and percentages

Inside @keyframes, each block is a point on the animation's timeline. from means 0% and to means 100%. Percentages in between add more points, so one animation can grow, fade and come back.

Percentages are points in the duration. The browser fills in the values between them.
Percentages are points in the duration. The browser fills in the values between them.
@keyframes pulse {
  0%, 100% { transform: scale(1);   opacity: 1; }
  50%      { transform: scale(1.4); opacity: .5; }
}

A few rules worth knowing:

  • Points that share styles can share a selector, as 0%, 100% does above.
  • If you leave out from or to, the browser uses the element's current style for that end.
  • An animation-timing-function written inside a keyframe applies from that point to the next one.
  • Names are case sensitive. slideIn and slidein are two different animations.

The animation properties, one by one

Property What it sets Common values
animation-name Which @keyframes to run the name, or none
animation-duration Length of one cycle 300ms, 2s (default 0s)
animation-timing-function The speed curve ease, linear, ease-in-out, steps(4)
animation-delay Wait before starting 0s, 200ms, -1s
animation-iteration-count How many cycles 1, 3, 2.5, infinite
animation-direction Which way each cycle runs normal, reverse, alternate, alternate-reverse
animation-fill-mode Styles before and after none, forwards, backwards, both
animation-play-state Running or frozen running, paused

The animation shorthand takes the same values in one line. The name and the keywords can move around, but two things are fixed: the first time value is always the duration and the second is always the delay.

The first time value is the duration, the second the delay. A shorthand written after a longhand resets it.
The first time value is the duration, the second the delay. A shorthand written after a longhand resets it.

Write it as name, duration, timing function, delay, count, direction, fill-mode. That is the order the demo prints.

Also avoid naming an animation after a keyword such as ease or infinite, because the shorthand may read it as that keyword.

animation-fill-mode: where the element ends up

By default the keyframes only apply while the animation runs. When it ends, the element goes back to its own CSS, which looks like a snap back. During a positive delay, it also shows its own CSS, not the first keyframe.

What the element shows before, during and after the animation for each fill-mode value.
What the element shows before, during and after the animation for each fill-mode value.
  • forwards keeps the styles of the last keyframe played. With alternate and an even count, that is the from keyframe.
  • backwards applies the first keyframe during the delay, so nothing flashes before it starts.
  • both does the two together. For an entrance animation with a delay, both is usually the one you want.

In the playground, set a 1.5s delay and switch between none and backwards to see the difference before the box moves.

Delays, negative delays and steps()

A positive delay makes an element wait. Giving each item in a row a slightly larger delay produces a wave. The catch is that on the first cycle, the later items sit still until their turn.

A negative delay starts the animation at once, as if it had already been running for that long. animation-delay: -0.6s on a 1.2s animation starts at the halfway point. Loaders built from several dots use this so the wave is there from the first frame.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delays and steps()</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h3 { margin: 0 0 6px; font-size: 14px; }
  h3 code { font-weight: 400; color: #4b5563; }
  .row { display: flex; gap: 12px; height: 44px; align-items: flex-end; padding: 0 4px 10px;
         background: #fff; border-radius: 10px; margin-bottom: 12px; }
  .dot { width: 16px; height: 16px; border-radius: 50%; background: #2563eb;
         animation: bounce 1.2s ease-in-out infinite; }
  .late .dot { background: #ea580c; }

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

  /* positive delays: each dot waits, then starts */
  .late .dot:nth-child(2) { animation-delay: .15s; }
  .late .dot:nth-child(3) { animation-delay: .3s; }
  .late .dot:nth-child(4) { animation-delay: .45s; }
  .late .dot:nth-child(5) { animation-delay: .6s; }
  /* negative delays: each dot starts at once, already part way through */
  .early .dot:nth-child(2) { animation-delay: -.15s; }
  .early .dot:nth-child(3) { animation-delay: -.3s; }
  .early .dot:nth-child(4) { animation-delay: -.45s; }
  .early .dot:nth-child(5) { animation-delay: -.6s; }

  .clocks { display: flex; gap: 22px; flex-wrap: wrap; }
  .clock { text-align: center; font-size: 13px; }
  .face { position: relative; width: 110px; height: 110px; border-radius: 50%; background: #fff;
          border: 3px solid #1d2330; margin: 0 auto 4px; }
  .hand { position: absolute; left: calc(50% - 1.5px); top: 10px; width: 3px; height: 45px;
          background: #dc2626; border-radius: 2px; transform-origin: 50% 100%;
          animation: spin 60s infinite; }
  .tick .hand { animation-timing-function: steps(60); }   /* 60 jumps of 6deg */
  .smooth .hand { animation-timing-function: linear; }
  @keyframes spin { to { transform: rotate(360deg); } }

  button { font: inherit; padding: 7px 14px; border-radius: 8px; border: 1px solid #c8ced8; background: #fff; cursor: pointer; margin-top: 10px; }
</style>
</head>
<body>
<h3>Positive delays <code>0s, .15s, .3s ...</code></h3>
<div class="row late" id="late"><i class="dot"></i><i class="dot"></i><i class="dot"></i><i class="dot"></i><i class="dot"></i></div>

<h3>Negative delays <code>0s, -.15s, -.3s ...</code></h3>
<div class="row early" id="early"><i class="dot"></i><i class="dot"></i><i class="dot"></i><i class="dot"></i><i class="dot"></i></div>

<div class="clocks">
  <div class="clock tick"><div class="face"><div class="hand"></div></div><code>steps(60)</code></div>
  <div class="clock smooth"><div class="face"><div class="hand"></div></div><code>linear</code></div>
</div>

<button id="restart">Restart all</button>

<script>
  // Restart: remove the animations, force a reflow, then let the CSS apply again.
  document.getElementById('restart').addEventListener('click', () => {
    const els = document.querySelectorAll('.dot, .hand');
    els.forEach((el) => { el.style.animation = 'none'; });
    void document.body.offsetWidth;
    els.forEach((el) => { el.style.animation = ''; });
  });
</script>
</body>
</html>
Orange dots wait for their delay. Blue dots use negative delays and are mid-wave at once. The left clock ticks with steps(60).

steps(n) replaces the smooth curve with n jumps. A second hand is rotate(360deg) over 60 seconds in steps(60), one 6 degree tick per second. The same trick plays a sprite sheet: move a background image by one frame width per step.

.hand { animation: spin 60s steps(60) infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

Running several animations at once

animation accepts a comma separated list. Each entry is its own animation with its own timing:

.badge {
  animation: fade-in 400ms ease-out both,
             wobble 2s ease-in-out 400ms infinite;
}

If two animations change the same property, such as transform, the one later in the list wins for that property. Give each a different property, or wrap the element and animate the wrapper. Longhands take lists too, and they pair up by position with animation-name.

Events, pausing and reduced motion

Animations fire DOM events. animationstart fires when it begins, animationiteration at each new cycle and animationend when it finishes. An infinite animation never fires animationend. These events bubble, so check event.animationName when a parent listens.

animation-play-state: paused freezes an animation where it is. Toggling a class that sets it is the simplest pause button.

Movement can make some people unwell. @media (prefers-reduced-motion: reduce) matches when the user has asked their system for less motion. Turn off floating and sliding there, and keep short opacity fades if the page depends on them.

For smooth motion, animate transform and opacity. Changing them does not force the browser to lay the page out again on every frame, unlike left, width or margin. CSS transform covers translate, rotate and scale.

This finished hero puts it together. The headline and text fade up once with both. The button appears on animationend of the second line. The shapes float until you press the pause button.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Animated hero</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; }
  .hero { position: relative; overflow: hidden; min-height: 400px; padding: 44px 24px; box-sizing: border-box;
          color: #fff; background: linear-gradient(135deg, #1e3a8a, #6d28d9); }

  /* floating shapes: transform only, so they stay smooth */
  .shape { position: absolute; border-radius: 50%; background: rgba(255, 255, 255, .14);
           animation: float 6s ease-in-out infinite alternate; }
  .s1 { width: 120px; height: 120px; right: -20px; top: 30px; }
  .s2 { width: 70px; height: 70px; right: 90px; bottom: 40px; animation-duration: 4s; animation-delay: -2s; }
  .s3 { width: 40px; height: 40px; left: 60%; top: 16px; animation-duration: 5s; animation-delay: -1s; }
  @keyframes float {
    from { transform: translateY(0) rotate(0deg); }
    to   { transform: translateY(-30px) rotate(40deg); }
  }
  .hero.paused .shape { animation-play-state: paused; }

  /* headline and text fade up once, then stay (fill-mode both) */
  .up { position: relative; animation: fade-up .7s ease-out both; }
  h1 { margin: 0 0 10px; font-size: clamp(26px, 7vw, 38px); line-height: 1.15; max-width: 14em; }
  p { margin: 0 0 20px; max-width: 30em; }
  p.up { animation-delay: .3s; }   /* after the shorthand's rule, and more specific */
  @keyframes fade-up {
    from { opacity: 0; transform: translateY(24px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  .cta { position: relative; display: inline-block; padding: 11px 20px; border-radius: 10px; border: 0;
         font: 600 15px system-ui, sans-serif; background: #fff; color: #4c1d95; cursor: pointer;
         visibility: hidden; }
  .cta.show { visibility: visible; animation: pop .35s ease-out; }
  @keyframes pop { from { opacity: 0; transform: scale(.9); } to { opacity: 1; transform: scale(1); } }

  .toggle { position: absolute; right: 12px; bottom: 12px; font: 13px system-ui, sans-serif; padding: 6px 12px;
            border-radius: 99px; border: 1px solid rgba(255, 255, 255, .5); background: rgba(0, 0, 0, .25);
            color: #fff; cursor: pointer; }

  /* reduced motion: no floating, no sliding, only a short fade */
  @media (prefers-reduced-motion: reduce) {
    .shape { animation: none; }
    @keyframes fade-up { from { opacity: 0; } to { opacity: 1; } }
    .cta.show { animation: none; }
  }
</style>
</head>
<body>
<section class="hero" id="hero">
  <i class="shape s1"></i><i class="shape s2"></i><i class="shape s3"></i>
  <h1 class="up">Plans that ship on Friday</h1>
  <p class="up" id="sub">One board for the launch checklist, the copy review and the people who sign off.</p>
  <button class="cta" id="cta">Start free</button>
  <button class="toggle" id="toggle">Pause motion</button>
</section>

<script>
  const hero = document.getElementById('hero');
  const cta = document.getElementById('cta');
  const toggle = document.getElementById('toggle');

  // Reveal the button when the second line has finished fading in.
  // animationend bubbles, so check the name before acting.
  document.getElementById('sub').addEventListener('animationend', (e) => {
    if (e.animationName === 'fade-up') cta.classList.add('show');
  });

  // Pause and resume the floating shapes with animation-play-state.
  toggle.addEventListener('click', () => {
    const paused = hero.classList.toggle('paused');
    toggle.textContent = paused ? 'Play motion' : 'Pause motion';
  });
</script>
</body>
</html>
Fade-up text, floating shapes, a play-state pause button, and a button revealed on animationend. With reduced motion on, only a short fade remains.

In the reduced motion block, the fade-up keyframes are replaced with an opacity-only version rather than removed. With animation: none there would be no animationend, and the button would never appear.

For hover effects that only need two states, a CSS transition is simpler than keyframes. For motion that starts with the page, see HTML animation on page load.

When it does not work

The full checklist is in CSS animation not working. The mistakes that come from the properties on this page:

What you see Cause Fix
Nothing moves, no error Shorthand has a name but no time value, so duration is 0s Add a duration after the name
The delay is ignored Only one time value, so it became the duration Write duration first, then delay
A delay set earlier is ignored animation shorthand written after animation-delay reset it Put longhands after the shorthand
It jumps back at the end Default fill-mode is none Add forwards or both
It runs once and never again Re-adding a class that is still there does not restart it Remove the class, force a reflow, add it again

The restart needs the browser to see the removal before the class returns. Reading a layout value in between does that:

el.classList.remove('run');
void el.offsetWidth;   // forces a reflow
el.classList.add('run');

Motion does not survive a screenshot, and an .html file sent as an attachment often opens as plain code on a phone. To show the animation itself, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to see the animation play and can press the pause button themselves. If you change the timing later, the same link shows the new version.

Questions people ask

What is the difference between from/to and percentages in @keyframes?

None in meaning. from is 0% and to is 100%. Percentages let you add points in between, such as 50% for the middle of the duration. You can list several percentages in one selector, for example 0%, 100% { ... }.

How do I make a CSS animation stay at the last keyframe?

Set animation-fill-mode: forwards, or add forwards to the shorthand. Without it the element returns to its own styles when the animation ends. Use both if it should also show the first keyframe during a delay.

How do I make a keyframes animation loop forever?

Set animation-iteration-count: infinite, or write infinite in the shorthand, for example animation: spin 1s linear infinite. Add alternate as the direction if it should go back and forth instead of jumping to the start.

Where does the delay go in the animation shorthand?

The first time value is the duration and the second is the delay. animation: fade 600ms 200ms waits 200ms and then runs for 600ms. With only one time value there is no delay.

Can I start an animation part way through?

Yes, with a negative animation-delay. animation-delay: -1s on a 2 second animation starts immediately at the halfway point. This is how staggered loaders avoid a pause on the first cycle.

Keep reading

CSS offset-path: move an element along a curveMake a dot follow a curve with CSS offset-path and offset-distance: path(), ray(), circle(),CSS scroll timelines: animations driven by scrollingRun a CSS animation on scroll instead of time: animation-timeline with scroll() and view(), CSS transition, one property at a timeHow CSS transition works: property, duration, delay, ease, cubic-bezier and steps, allow-disCSS will-change: prepare an animation without breaking the pagewill-change tells the browser a property is about to animate. Add it just before, remove it Typewriter effect with CSS (and when you need JavaScript)Typewriter text in pure CSS with steps() and ch, why normal fonts break it, and a JavaScriptrequestAnimationFrame in JavaScript: build a smooth animation loopHow requestAnimationFrame works: the timestamp, speed that does not depend on frame rate, caCSS animation not workingKeyframes defined and nothing moves. Name mismatch, missing duration, a non animatable propeHTML animation on page loadHow to run an animation when an HTML page loads, with CSS keyframes, staggered delays, scrolCSS transform: move, rotate, scale and skew an elementHow CSS transform moves, rotates, scales and skews an element without touching layout. Live CSS hover transitionHow a CSS hover transition works, why the transition belongs on the resting state and not inMake a loading spinner with CSSA CSS loading spinner from one div and a keyframe: ring, dots, bars, pulse and skeletons, siClaude HTML animationHow to ask Claude for animated HTML that runs smoothly, stays on brand and does not distract