Skip to main content

⏱️ Animation Properties and Timing

If @keyframes is the choreography, the animation properties are the director's controls: how long, how fast, how many times, which way, and what happens before and after. This lesson gives you fine-grained command over every one of them — including custom easing curves and stacking multiple animations on a single element.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain all eight animation properties and set them individually or via shorthand
  • Choose the right timing function — including custom cubic-bezier() and steps() curves
  • Use delay (including negative delay) to choreograph staggered, offset sequences
  • Control looping with iteration-count and direction, and hold end states with fill-mode
  • Apply multiple animations to one element and pause them with play-state

Estimated Time: 35–45 minutes  •  Difficulty: Intermediate

Hands-on: Build a small interactive "animation lab" that lets you tweak every property live.

In This Lesson

The Eight Properties at a Glance

The @keyframes rule defines what changes; the eight animation properties define how the browser plays it. They fall into three natural groups — timing, playback, and state:

graph LR A[Animation properties] --> B[Timing] A --> C[Playback] A --> D[State] B --> E[duration] B --> F[delay] B --> G[timing-function] C --> H[iteration-count] C --> I[direction] C --> J[fill-mode] D --> K[play-state] D --> L[name]
PropertyControlsDefault
animation-nameWhich @keyframes to playnone
animation-durationLength of one cycle0s
animation-timing-functionPace / easing curveease
animation-delayWait before starting0s
animation-iteration-countHow many times to repeat1
animation-directionForward, reverse, or alternatenormal
animation-fill-modeStyles before/after runningnone
animation-play-stateRunning or pausedrunning

Knowing these is like knowing the controls of a professional video editing suite: each dial adjusts one aspect of the final motion, and together they let you dial in exactly the feel you want.

Timing Functions in Depth

The animation-timing-function shapes the pace of movement — how progress maps to time. The same fade over the same second can feel mechanical, natural, snappy, or springy depending purely on this curve.

Common easing curves plotted as progress over time A graph with time on the x-axis and progress on the y-axis, showing linear as a straight diagonal and ease, ease-in, and ease-out as curves of differing shape. Time → Progress → linear ease ease-in ease-out
Figure 1 — Steeper regions mean faster movement. linear is constant; ease-in starts slow; ease-out ends slow; ease does both gently.

The keyword presets

  • linear — constant speed. Best for continuous spins and progress bars.
  • ease (default) — slow start, quick middle, slow end. A safe, natural feel.
  • ease-in — slow start, accelerates. Good for exits (leaving the screen).
  • ease-out — fast start, decelerates. Best for entrances (arriving on screen).
  • ease-in-out — a more pronounced version of ease. Great for oscillating loops.

💡 The rule of thumb

Use ease-out for things entering the screen (they arrive quickly then settle, which feels responsive) and ease-in for things leaving (they linger then whisk away). This single habit makes UI motion feel instantly more polished.

Custom curves with cubic-bezier()

For precise control — including "overshoot" effects presets can't produce — define your own curve:

/* cubic-bezier(x1, y1, x2, y2) */
.springy {
  animation-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

The curve always runs from (0,0) to (1,1). The two control points bend it. Because the y values may go below 0 or above 1, the animation can dip past its start or shoot past its end before settling — that's what gives a bouncy, elastic feel. Design them visually at cubic-bezier.com.

Discrete steps with steps()

Where cubic-bezier() is smooth, steps() jumps in equal discrete increments — perfect for sprite-sheet animations, typewriter effects, and mechanical counters:

.typewriter {
  /* reveal 24 characters, one hard step at a time */
  animation: type 2s steps(24, end);
}

The second argument (start or end) controls whether the change happens at the beginning or end of each step. It's the difference between an old flip-clock advancing crisply versus sliding.

Duration, Delay & Choreography

Choosing a duration

Duration profoundly shapes perception. As a quick guide:

DurationFeels likeGood for
50–150msInstant but noticeableClicks, toggles
150–300msQuick, responsiveHover feedback
300–500msSmooth transitionPanels, content swaps
500ms–1sDeliberateEntrances, exits
1s+DramaticLoaders, storytelling

Staggering with delay

Delays turn a group of identical animations into a choreographed sequence. Give each item a slightly larger delay for a cascade:

.item { animation: fade-in-up 0.5s ease-out backwards; }
.item:nth-child(1) { animation-delay: 0.05s; }
.item:nth-child(2) { animation-delay: 0.10s; }
.item:nth-child(3) { animation-delay: 0.15s; }
.item:nth-child(4) { animation-delay: 0.20s; }

The result is a domino effect that naturally guides the eye down the list. (Note backwards here — it holds the first keyframe during the delay so items don't flash before their turn. More on fill modes shortly.)

A modern shortcut is to compute the delay from the index with a custom property, avoiding the repetitive nth-child rules:

.item {
  animation: fade-in-up 0.5s ease-out backwards;
  animation-delay: calc(var(--i) * 0.05s);
}
/* <li class="item" style="--i:3"> */

Negative delay

A negative delay starts the animation as if it had already been running for that long — useful for offsetting looping animations so they don't all move in lockstep:

.wave-dot { animation: bob 1.2s ease-in-out infinite; }
.wave-dot:nth-child(1) { animation-delay: -0.9s; }
.wave-dot:nth-child(2) { animation-delay: -0.6s; }
.wave-dot:nth-child(3) { animation-delay: -0.3s; }

It's like joining a film halfway through: you skip the opening and drop straight into the action.

Iteration & Direction

How many times: iteration-count

.once     { animation-iteration-count: 1; }        /* default */
.thrice   { animation-iteration-count: 3; }        /* draw attention, then stop */
.forever  { animation-iteration-count: infinite; } /* loaders, background motion */
.partial  { animation-iteration-count: 2.5; }      /* stops halfway through the 3rd */

Finite counts are great for a quick "notice me" flourish (three pulses, say) before the element goes still; infinite belongs on spinners and ambient effects.

Which way: direction

By default every cycle replays from 0% to 100%, then snaps back to 0% to start again. animation-direction changes that:

The four animation-direction values Four rows showing how normal, reverse, alternate and alternate-reverse play successive cycles forward or backward. normal reverse alternate alternate-reverse ▶ ▶ ▶ ▶ ▶ ▶ ◀ ◀ ◀ ◀ ◀ ◀ ▶ ▶ ▶ ◀ ◀ ◀ ◀ ◀ ◀ ▶ ▶ ▶ cycle 1 cycle 2
Figure 2 — alternate reverses on every other cycle, producing smooth back-and-forth motion with no jump.

The alternate value is the secret to seamless oscillation — a pendulum, a breathing glow, a hovering float — because the end of one cycle flows straight into the reversed next cycle with no visible snap:

@keyframes slide { to { transform: translateX(100px); } }

.pendulum {
  animation: slide 2s ease-in-out infinite alternate;
}

For continuous rotation, prefer plain normal with a linear curve — a full 0–360° turn already loops seamlessly, so alternating would look wrong.

Fill Modes & Play State

What happens before and after: fill-mode

By default (none), an element snaps back to its authored CSS the instant the animation ends — which is why a fade-in element often flickers back to invisible. animation-fill-mode fixes this by deciding which keyframe values persist outside the run:

ValueBefore it runs (during delay)After it ends
noneAuthored stylesAuthored styles
forwardsAuthored stylesHolds the last keyframe
backwardsHolds the first keyframeAuthored styles
bothHolds the first keyframeHolds the last keyframe
/* Fade in and STAY visible */
.fade-in-stay {
  opacity: 0;                       /* authored starting state */
  animation: fade-in 1s forwards;   /* holds opacity:1 at the end */
}

/* Delayed entrance that waits off-screen instead of on-screen */
.delayed-entrance {
  animation: slide-in 1s 2s backwards; /* first keyframe held during the 2s delay */
}

⚠️ The most common animation bug

"My element animates into place, then jumps back to where it started." That's a missing forwards. Whenever an animation should leave an element in a new visual state, add animation-fill-mode: forwards (or use both).

Pausing and resuming: play-state

animation-play-state freezes an animation in place and later resumes it from the exact same frame. A pure-CSS pattern pauses a marquee on hover:

.ticker { animation: scroll 15s linear infinite; }
.ticker:hover { animation-play-state: paused; }

For richer control — play/pause buttons, for instance — toggle it from JavaScript:

const el = document.querySelector('.animated');
const btn = document.querySelector('#toggle');

btn.addEventListener('click', () => {
  const state = getComputedStyle(el).animationPlayState;
  el.style.animationPlayState = state === 'running' ? 'paused' : 'running';
  btn.textContent = state === 'running' ? 'Play' : 'Pause';
});

Multiple Animations on One Element

One element can run several animations at once — just comma-separate complete definitions in the shorthand. Each runs independently with its own timing:

.hero-badge {
  animation:
    fade-in  0.6s ease-out,
    slide-up 0.8s ease-in-out,
    pulse    2s   ease  1s infinite;
}

Here the badge fades in, slides up, and then — starting one second in — pulses forever. It's like a dancer spinning while crossing the stage: several movements layered into one performance.

You can also set the sub-properties as parallel comma lists. Values with fewer entries than animations simply repeat (wrap around):

.loader {
  animation-name:            rotate, pulse-opacity, pulse-size;
  animation-duration:        1.5s,   2s,            3s;
  animation-timing-function: linear, ease,          ease;
  animation-iteration-count: infinite; /* one value applies to all three */
}

A rich loader from simple parts

@keyframes rotate        { to { transform: rotate(360deg); } }
@keyframes pulse-opacity { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }

.loader {
  width: 48px; height: 48px; border-radius: 50%;
  border: 3px solid transparent;
  border-top-color: var(--primary-color);
  border-bottom-color: var(--primary-color);
  animation:
    rotate        1.5s linear infinite,
    pulse-opacity 2s   ease   infinite;
}

Two trivial keyframe rules combine into a spinner that rotates and breathes — far more characterful than either alone.

Hands-on Exercise

🏋️ Build a Live Animation Lab

Objective: Wire up controls that rebuild an element's animation shorthand on the fly, so you can feel how each property changes the motion.

Instructions

  1. Create a box and a control panel with inputs for timing-function, duration, delay, iteration-count, direction, and fill-mode.
  2. On "Play", read every control and assemble the animation string, then assign it.
  3. To replay reliably, clear the animation and force a reflow before reapplying (see the hint).
  4. Try to reproduce a "bouncy entrance": short duration, a springy cubic-bezier, and forwards.

Starter code

<div class="controls">
  <label>Timing
    <select id="timing">
      <option>linear</option>
      <option selected>ease</option>
      <option>ease-in</option>
      <option>ease-out</option>
      <option value="cubic-bezier(0.68,-0.55,0.27,1.55)">springy</option>
      <option value="steps(8, end)">steps(8)</option>
    </select>
  </label>
  <label>Duration (s) <input id="dur" type="number" value="2" step="0.1" min="0.1"></label>
  <label>Delay (s) <input id="delay" type="number" value="0" step="0.1"></label>
  <label>Count <input id="count" value="1"></label>
  <label>Direction
    <select id="dir">
      <option>normal</option><option>reverse</option>
      <option>alternate</option><option>alternate-reverse</option>
    </select>
  </label>
  <label>Fill
    <select id="fill">
      <option>none</option><option selected>forwards</option>
      <option>backwards</option><option>both</option>
    </select>
  </label>
  <button id="play">Play</button>
</div>

<div class="stage"><div class="box"></div></div>

<style>
  .stage { height: 220px; border: 1px solid #ddd; position: relative; overflow: hidden; }
  .box { width: 70px; height: 70px; background: #3498db; border-radius: 8px;
         position: absolute; top: 50%; translate: 0 -50%; }
  @keyframes move-right { from { left: 0; } to { left: calc(100% - 70px); } }
</style>
💡 Hint — forcing a replay

Assigning the same animation value again does nothing because the value hasn't changed. Reset it, read offsetWidth to force a synchronous reflow, then set the real value:

box.style.animation = 'none';
void box.offsetWidth;      // force reflow
box.style.animation = str; // now it restarts
✅ Solution (JavaScript)
const box  = document.querySelector('.box');
const $ = id => document.getElementById(id);

$('play').addEventListener('click', () => {
  const str = [
    'move-right',
    `${$('dur').value}s`,
    $('timing').value,
    `${$('delay').value}s`,
    $('count').value,
    $('dir').value,
    $('fill').value
  ].join(' ');

  box.style.animation = 'none';   // clear
  void box.offsetWidth;           // reflow
  box.style.animation = str;      // replay
});

For a bouncy entrance, pick the springy timing, a duration around 0.6s, count 1, and fill forwards.

🎯 Quick Quiz

Question 1: An element fades into place then abruptly jumps back to invisible. Which property fixes it?

Question 2: Which timing function is best for a smooth back-and-forth pendulum using alternate?

Question 3: What does a negative animation-delay do?

Summary & Quiz

🎉 Key Takeaways

  • There are eight animation properties, grouped into timing, playback, and state.
  • Timing functions shape the feel: ease-out for entrances, ease-in for exits, cubic-bezier() for overshoot, steps() for discrete motion.
  • Delay choreographs sequences; negative delay offsets loops so they don't move in lockstep.
  • alternate direction gives seamless oscillation; forwards holds the end state.
  • Comma-separate to run multiple animations on one element; play-state pauses and resumes them.

📚 Further Reading

🚀 What's Next?

You can now choreograph motion with precision. But a beautiful animation that stutters is worse than none — so next we'll dig into the browser's rendering pipeline and learn to keep every animation at a smooth 60fps.

🎉 Well done!

Timing is everything. Next: making sure that timing never drops a frame.