⏱️ 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()andsteps()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:
| Property | Controls | Default |
|---|---|---|
animation-name | Which @keyframes to play | none |
animation-duration | Length of one cycle | 0s |
animation-timing-function | Pace / easing curve | ease |
animation-delay | Wait before starting | 0s |
animation-iteration-count | How many times to repeat | 1 |
animation-direction | Forward, reverse, or alternate | normal |
animation-fill-mode | Styles before/after running | none |
animation-play-state | Running or paused | running |
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.
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 ofease. 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:
| Duration | Feels like | Good for |
|---|---|---|
| 50–150ms | Instant but noticeable | Clicks, toggles |
| 150–300ms | Quick, responsive | Hover feedback |
| 300–500ms | Smooth transition | Panels, content swaps |
| 500ms–1s | Deliberate | Entrances, exits |
| 1s+ | Dramatic | Loaders, 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:
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:
| Value | Before it runs (during delay) | After it ends |
|---|---|---|
none | Authored styles | Authored styles |
forwards | Authored styles | Holds the last keyframe |
backwards | Holds the first keyframe | Authored styles |
both | Holds the first keyframe | Holds 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
- Create a box and a control panel with inputs for timing-function, duration, delay, iteration-count, direction, and fill-mode.
- On "Play", read every control and assemble the
animationstring, then assign it. - To replay reliably, clear the animation and force a reflow before reapplying (see the hint).
- Try to reproduce a "bouncy entrance": short duration, a springy
cubic-bezier, andforwards.
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.
alternatedirection gives seamless oscillation;forwardsholds the end state.- Comma-separate to run multiple animations on one element;
play-statepauses 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.