Lesson 7
CSS Animations
Transitions are reactive - they need a trigger (hover, class change, etc.). Animations are proactive - they run on their own, can loop, can have multiple steps, and can fire on page load. They're the tool for anything more complex than "smoothly change one value when something happens."
Transitions vs Animations - when to use which
| Use transitions when... | Use animations when... |
|---|---|
| A → B (two states) | A → B → C → ... (multiple steps) |
| Triggered by user action or state change | Runs automatically (page load, always) |
| Plays once, reverses when trigger leaves | Can loop, alternate, pause, restart |
| Simpler syntax, less control | Full timeline control |
@keyframes - defining the sequence
A @keyframes rule defines the steps of an animation by name:
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
from is an alias for 0%, to is an alias for 100%. You can use any percentage steps:
@keyframes bounce {
0% { transform: translateY(0); }
30% { transform: translateY(-40px); }
50% { transform: translateY(0); }
70% { transform: translateY(-20px); }
100% { transform: translateY(0); }
}
slide-in: ran once on page load (click replay to see again)
bounce: multi-step keyframes running infinitely
The animation shorthand
animation: <name> <duration> <timing> <delay> <count> <direction> <fill-mode> <play-state>;
/* Example */
animation: slide-in 0.6s ease-out 0s 1 normal both running;
/* Minimum needed */
animation: slide-in 0.6s;
The individual properties:
animation-name- which @keyframes to useanimation-duration- total time for one cycleanimation-timing-function- same curves as transitions (ease, linear, cubic-bezier, etc.)animation-delay- wait before startinganimation-iteration-count- how many times (1,3,infinite)animation-direction-normal,reverse,alternate,alternate-reverseanimation-fill-mode- what styles to show before/after the animation (explained in detail below)animation-play-state-runningorpaused
Live demo: the animation shorthand applied
@keyframes slide-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.box {
animation: slide-in 0.6s ease-out both;
}
@keyframes bounce {
0% { transform: translateY(0); }
30% { transform: translateY(-40px); }
50% { transform: translateY(0); }
70% { transform: translateY(-20px); }
100% { transform: translateY(0); }
}
.ball {
animation: bounce 1s ease-in-out infinite;
}
Fill modes - what happens before and after
An animation has a start and an end. But what styles does the element show before the animation starts (especially during a delay) and after it finishes? By default: nothing useful. The element shows its base styles, ignoring the keyframes entirely outside the active window. animation-fill-mode fixes this.
The problem fill-mode solves
Imagine a fade-in with a 1-second delay. The element's base opacity is 0.5. Without fill-mode:
- During the delay (0-1s): element shows at opacity 0.5 (base state) - visible flash
- Animation plays (1-1.5s): opacity goes 0 → 1
- After animation (1.5s+): element snaps back to opacity 0.5 (base state) - jarring
Fill-mode lets you control what happens in those "outside" windows:
The four values
none(default) - element shows base styles outside the animation. Causes flashes and snap-backs.backwards- during the delay, show the first keyframe (0%) instead of base styles. Prevents the pre-animation flash.forwards- after the animation ends, hold the final keyframe (100%) instead of reverting. Prevents snap-back.both- appliesbackwardsto the delay ANDforwardsto the end. Covers both problems at once.
Visual: which fill-mode covers which time window
both is what you almost always want: It covers both problems - no flash during the delay (backwards) and no snap-back after completion (forwards). They don't conflict because they apply to different time windows.
Concrete example
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.box {
opacity: 0.5; /* base state */
animation: fade-in 0.5s ease 1s both;
/* ^ 1s delay ^ fill: both */
}
/* Timeline with fill: both:
0s - 1s (delay): opacity: 0 (from keyframe, thanks to "backwards")
1s - 1.5s: opacity: 0 → 1 (animating)
1.5s+ (after): opacity: 1 (to keyframe, thanks to "forwards")
Without "both" (fill: none):
0s - 1s (delay): opacity: 0.5 (base state - visible flash!)
1s - 1.5s: opacity: 0 → 1 (animating)
1.5s+ (after): opacity: 0.5 (base state - snaps back!)
*/
Three boxes with a 1s delay - watch what happens during the delay and after completion
@keyframes fade-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
<!-- HTML -->
<div class="fill-demo-item fill-none">fill: none</div>
<div class="fill-demo-item fill-forwards">fill: forwards</div>
<div class="fill-demo-item fill-both">fill: both</div>
/* CSS - No fill: visible at base state during delay, snaps back after */
.fill-none { animation: fade-in 0.5s ease 1s 1 normal none; }
/* Forwards: stays at final state after animation ends */
.fill-forwards { animation: fade-in 0.5s ease 1s 1 normal forwards; }
/* Both: shows first keyframe during delay, stays at last after */
.fill-both { animation: fade-in 0.5s ease 1s 1 normal both; }
Rule of thumb: Use both for entrance animations. It ensures the element starts in the "from" state (even during delay) and stays in the "to" state after completion. Without it, you get flashes of the base state.
Direction - alternate and reverse
All four direction values animating a box to the right
@keyframes slide-right {
from { transform: translateX(0); }
to { transform: translateX(150px); }
}
/* Normal: 0% → 100%, 0% → 100%, ... */
.dir-normal { animation: slide-right 1.5s ease-in-out infinite; animation-direction: normal; }
/* Reverse: 100% → 0%, 100% → 0%, ... */
.dir-reverse { animation: slide-right 1.5s ease-in-out infinite; animation-direction: reverse; }
/* Alternate: 0% → 100%, 100% → 0%, 0% → 100%, ... */
.dir-alternate { animation: slide-right 1.5s ease-in-out infinite; animation-direction: alternate; }
/* Alternate-reverse: 100% → 0%, 0% → 100%, ... */
.dir-alt-rev { animation: slide-right 1.5s ease-in-out infinite; animation-direction: alternate-reverse; }
alternate is particularly useful for looping animations that should feel smooth - like a pulse or breathing effect - because it reverses direction each cycle instead of jumping back to the start.
Pause and play
You can pause animations with animation-play-state. This is useful for reducing motion when a user isn't actively looking, or for interactive controls:
Click the spinner to toggle pause/play
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #eee;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.spinner.paused {
animation-play-state: paused;
}
Accessibility pattern: Use @media (prefers-reduced-motion: reduce) to pause or remove animations for users who've requested less motion:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
Scroll-driven animations - the new frontier
Traditionally, animations run on a time-based timeline. Scroll-driven animations (Chrome 115+, Firefox 110+) let you tie animation progress to scroll position instead of elapsed time. No JavaScript needed.
There are two types of scroll timeline:
scroll()- progress tracks how far a scroll container has been scrolled (0% at top, 100% at bottom)view()- progress tracks an element's visibility within a scroll container (0% when entering, 100% when fully visible or leaving)
Scroll progress bar
Scroll this box - the blue bar at the top grows with scroll progress
Scroll down to see the progress bar grow...
Keep going...
Almost there...
Bottom!
@keyframes grow-width {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.scroll-progress {
position: sticky;
top: 0;
height: 4px;
background: blue;
transform-origin: left;
animation: grow-width linear;
animation-timeline: scroll(nearest block);
}
Key concept: animation-timeline: scroll() replaces the default time-based timeline. The animation still uses the same @keyframes - but progress is driven by scroll position instead of a clock. Duration becomes irrelevant (you can omit it or set it to auto).
The scroll() function arguments
scroll() takes two optional arguments:
animation-timeline: scroll( <scroller> <axis> );
/* Defaults: */
animation-timeline: scroll(nearest block);
Argument 1: which scroller to track
nearest(default) - the closest ancestor that has scrollable overflow. In the demo above, that's the.scroll-containerdiv.root- the document's root scroll container (the viewport / page scroll). Use this for full-page progress bars.self- the element itself is the scroll container (for elements with their own overflow scroll).
Argument 2: which axis
block(default) - the block axis (vertical in horizontal writing modes, i.e. up/down scroll).inline- the inline axis (horizontal scroll in normal writing modes).x- always horizontal, regardless of writing mode.y- always vertical, regardless of writing mode.
Practical examples
/* Track the page scroll (full-page progress bar) */
animation-timeline: scroll(root block);
/* Track a horizontal carousel scroll */
animation-timeline: scroll(nearest inline);
/* Track the element's own scroll (e.g. a scrollable panel) */
animation-timeline: scroll(self block);
/* Shortest form - defaults to nearest ancestor, vertical */
animation-timeline: scroll();
Most common usage: scroll(root block) for page-level progress indicators, and scroll(nearest block) (or just scroll()) for animations tied to a specific scrollable container.
View-driven reveal animation
view() ties progress to an element's intersection with the viewport (or scroll container). Combined with animation-range, you can control exactly when the animation starts and ends:
Scroll inside this box - cards animate as they enter and leave the visible area
@keyframes reveal {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal-item {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
/* Animates from invisible → visible as it enters the viewport */
}
Note: if all cards are already visible, scroll down past them and back up - the animation is tied to their intersection with the browser viewport, not a nested container.
animation-range values:
entry 0%- the element's top edge hits the bottom of the viewportentry 100%- the element is fully inside the viewportexit 0%- the element starts leaving the viewport at the topexit 100%- the element is fully out of viewcover,contain- shorthand ranges for common cases
Multiple animations on one element
You can stack animations with comma separation:
This box has three animations: slide-up, pulse, and colour cycle - all running independently
.element {
animation:
fade-in 0.5s ease both,
slide-up 0.5s ease both,
pulse 2s ease-in-out 1s infinite alternate;
}
Each animation runs independently - different durations, delays, and iteration counts.
Practical patterns
Staggered entrance
Click replay to see staggered fade-up
@keyframes fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.stagger-item {
animation: fade-up 0.4s ease both;
}
.stagger-item:nth-child(1) { animation-delay: 0s; }
.stagger-item:nth-child(2) { animation-delay: 0.1s; }
.stagger-item:nth-child(3) { animation-delay: 0.15s; }
.stagger-item:nth-child(4) { animation-delay: 0.2s; }
/* Or with custom properties for flexibility: */
.stagger-item {
animation-delay: calc(var(--i, 0) * 0.1s);
}
/* Set --i via inline style or attr() in the HTML */
Loading spinner
A simple border-based spinner - infinite linear rotation
Loading...@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #eee;
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
Pulse / breathing effect
Badge pulsing continuously using alternate direction
@keyframes pulse {
from { transform: scale(1); opacity: 1; }
to { transform: scale(1.05); opacity: 0.8; }
}
.badge {
animation: pulse 1.5s ease-in-out infinite alternate;
}
Animation events in JavaScript
You can listen for animation lifecycle events:
element.addEventListener('animationstart', (e) => { /* fired when animation begins */ });
element.addEventListener('animationend', (e) => { /* fired when animation completes */ });
element.addEventListener('animationiteration', (e) => { /* fired at start of each loop */ });
Useful for: removing elements after an exit animation, chaining animations in sequence, or syncing JS state with CSS animations.
Retrieval check
Question 1
What's the key difference between transitions and animations?
Question 2
What does animation-fill-mode: both do?
Question 3
How do you make a scroll-driven animation instead of time-based?
Question 4
You want a looping animation that reverses direction each cycle. What animation-direction value?
Question 5
In animation-range: entry 0% entry 100%, what does "entry 0%" mean?