← Back to Index

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)

Slide in

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:

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:

  1. During the delay (0-1s): element shows at opacity 0.5 (base state) - visible flash
  2. Animation plays (1-1.5s): opacity goes 0 → 1
  3. 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

Visual: which fill-mode covers which time window

TIME BEFORE (delay period) ACTIVE ANIMATION (keyframes 0% → 100%) AFTER (finished) fill: none → base styles fill: none → base styles fill: backwards → "from" (0%) keyframe fill: forwards → "to" (100%) keyframe fill: both → "from" (0%) keyframe fill: both → "to" (100%) keyframe animating... backwards (shows 0% during delay) forwards (holds 100% after end) none (reverts to base styles)
Why 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

fill: none
fill: forwards
fill: both
@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

normal
reverse
alternate
alt-rev
@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;
  }
}
Ad

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 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

Argument 2: which axis

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

Card A - I animate as I enter the viewport
Card B - scroll me out of view and back
Card C - the effect reverses when I leave
@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:

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

3 anims
.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

Item 1
Item 2
Item 3
Item 4
@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

3 notifications
@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?

Animations use GPU, transitions don't
Animations can run autonomously; transitions need a trigger
Transitions can loop, animations can't
They're interchangeable - just different syntax

Question 2

What does animation-fill-mode: both do?

Plays the animation in both directions alternately
Applies both transform and opacity at once
Shows first keyframe during delay AND holds last keyframe after end
Runs on both desktop and mobile browsers

Question 3

How do you make a scroll-driven animation instead of time-based?

Set animation-duration to scroll
Set animation-timeline: scroll() or view()
Use @keyframes with scroll percentages
Add scroll-behavior: animated to the container

Question 4

You want a looping animation that reverses direction each cycle. What animation-direction value?

reverse
alternate
both
infinite

Question 5

In animation-range: entry 0% entry 100%, what does "entry 0%" mean?

The element is at the top of the viewport
The element's top edge hits the bottom of the viewport
The element is 0% visible (fully hidden)
The scroll container is at 0% scroll position