Chapter 8 — Deep Dive: Transitions and Animations
Motion in UI serves communication — it confirms actions, guides attention, and
signals state changes. CSS provides two motion systems: transitions,
which animate between two CSS states triggered by an event, and
animations, which run on a timeline defined by keyframes, independent
of state changes. A third system — scroll-driven animations — ties
animation progress to scroll position without JavaScript.
1. Transitions
The transition shorthand in full
/* transition: property duration easing delay */
.btn {
transition: background-color 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease; /* multiple */
transition: all 0.2s ease; /* all properties — avoid, causes perf issues */
transition: color 0.2s ease 0.1s; /* 0.1s delay before starting */
}
/* Longhand — when you need different settings per property */
.btn {
transition-property: transform, opacity;
transition-duration: 0.3s, 0.2s; /* one per property */
transition-timing-function: ease-out, linear;
transition-delay: 0s, 0.1s;
}
/* Best practice: put transition on the BASE state, not the :hover state */
.btn { background: #58a6ff; transition: background 0.2s ease; }
.btn:hover { background: #79c0ff; }
/* Putting it on :hover only would mean no transition OUT of hover state */
Hover to demonstrate — live transition demos
lift
+shadow
spring
scale
colour
shift
180°
spin
transition-behavior — discrete properties
/* Most properties transition continuously (opacity, transform, color) */
/* Discrete properties (display, visibility) snap — they can't interpolate */
/* Old problem: animating display: none → display: block */
.modal {
display: none;
opacity: 0;
transition: opacity 0.3s;
}
.modal.open {
display: block; /* snaps immediately — opacity transition never runs */
opacity: 1;
}
/* Modern fix: allow-discrete (Chrome 117+, Firefox 129+) */
.modal {
display: none;
opacity: 0;
transition: opacity 0.3s, display 0.3s allow-discrete;
}
.modal.open {
display: block;
opacity: 1;
/* display switches at END of transition (fade out then hide) */
/* or START (show then fade in) depending on direction */
}
/* Also enables animating the Popover API and <dialog> in/out */
@starting-style {
.modal.open {
opacity: 0; /* defines the "before entering" state */
}
}
2. Easing Functions
The easing function controls the rate of change through a transition or
animation — how fast it accelerates and decelerates. Choosing the right easing is as
important as the duration. Natural motion rarely moves at a constant speed.
ease
cubic-bezier(0.25,0.1,0.25,1)
Starts fast, decelerates. The default. Good general purpose.
ease-in
cubic-bezier(0.42,0,1,1)
Starts slow, ends fast. Use for exits — elements leaving the screen accelerate away naturally.
ease-out
cubic-bezier(0,0,0.58,1)
Starts fast, ends slow. Use for entrances — elements arriving decelerate to a stop.
ease-in-out
cubic-bezier(0.42,0,0.58,1)
Slow start and end, fast middle. Use for looping animations and transitions where the element stays on screen.
linear
cubic-bezier(0,0,1,1)
Constant speed. Feels mechanical. Use for colour/opacity transitions and spinner animations.
spring (custom)
cubic-bezier(0.34,1.56,0.64,1)
Overshoots then settles. Use for scale/position feedback — gives UI a physical, springy feel.
steps()
steps(4, end)
Jumps through N discrete frames. Use for sprite animations, typewriter effects, and pixel art.
linear() new
linear(0,0.5 25%,1)
Modern multi-point linear easing. Can approximate any curve including springs and bounces with full CSS — no cubic-bezier needed.
/* cubic-bezier(x1, y1, x2, y2) — define your own curve */
/* P0 = (0,0) start. P3 = (1,1) end. P1 and P2 are control handles. */
/* y values can exceed 0–1 range to create overshoot (spring effect) */
.spring { transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); }
.snappy { transition-timing-function: cubic-bezier(0.2, 0, 0, 1); }
.material { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }
/* steps() — discrete jumps */
.typewriter { transition-timing-function: steps(20, end); }
/* step-start = steps(1, start), step-end = steps(1, end) */
/* Modern linear() — approximate any curve with control points */
.bounce {
transition-timing-function: linear(
0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938 16.7%,
1.017, 1.077, 1.121, 1.149 24.3%, 1.159, 1.163 26.3%, 1.154 28.3%,
1.129 30.7%, 1.051 35.7%, 1.017 38.2%, 0.991, 0.977 42.7%, 0.974 44.1%,
0.975 45.7%, 1.001 55%, 1.004 60%, 1
);
}
3. Keyframe Animations
@keyframes syntax
/* Named keyframe block */
@keyframes slide-in {
from { /* from = 0% */
opacity: 0;
transform: translateY(20px);
}
to { /* to = 100% */
opacity: 1;
transform: translateY(0);
}
}
/* Multiple stops */
@keyframes progress-pulse {
0% { transform: scaleX(0); opacity: 1; }
60% { transform: scaleX(0.8); opacity: 1; }
100% { transform: scaleX(1); opacity: 0; }
}
/* Group stops that share styles */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
All animation properties
| Property | Values | Notes |
| animation-name |
@keyframes name · none |
References the @keyframes block. Multiple names comma-separated to run several animations. |
| animation-duration |
time (s or ms) |
How long one cycle takes. 0s is valid — the animation runs but is invisible (useful for staggered delays). |
| animation-timing-function |
ease · linear · cubic-bezier() · steps() · linear() |
Applied per keyframe interval, not across the whole animation — important for multi-stop keyframes. |
| animation-delay |
time (s or ms) · negative values |
Negative delay starts the animation mid-way through. animation-delay: -0.5s with a 1s duration starts at the 50% keyframe — great for staggering. |
| animation-iteration-count |
number · infinite |
How many times the animation runs. infinite for spinners and loaders. Fractional values are valid (0.5 plays half a cycle). |
| animation-direction |
normal · reverse · alternate · alternate-reverse |
alternate plays forward then backward — seamless loops without needing mirrored keyframes. Use for pulsing effects. |
| animation-fill-mode |
none · forwards · backwards · both |
forwards: element keeps the final keyframe state after the animation ends. backwards: applies 0% keyframe during the delay. both: does both. Use forwards for one-shot entrance animations. |
| animation-play-state |
running · paused |
Pause/resume via CSS or JS. A paused animation stays at its current frame. Toggle with :hover to pause spinners on hover. |
| animation-composition |
replace · add · accumulate |
Controls how multiple animations combine when they target the same property. add stacks transforms additively. |
| animation |
shorthand |
Order: name duration easing delay iterations direction fill-mode play-state. The fill-mode and play-state are often omitted. Multiple animations: comma-separated. |
Live animation demos
pulse
ease-in-out alternate
Staggered animations with negative delay
/* Stagger items without JavaScript using negative delay */
.list-item {
animation: slide-in 0.4s ease-out both;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 60ms; }
.list-item:nth-child(3) { animation-delay: 120ms; }
.list-item:nth-child(4) { animation-delay: 180ms; }
/* Or with a CSS custom property set per element via JS or inline style */
.list-item {
animation-delay: calc(var(--index) * 60ms);
}
/* <li class="list-item" style="--index: 0"> */
/* <li class="list-item" style="--index: 1"> */
4. Performance — Composited Properties
The browser rendering pipeline has several stages: Style → Layout → Paint → Composite.
Properties that trigger Layout (reflow) force the browser to recalculate the geometry
of every affected element — expensive. Properties that only trigger Paint or Composite
are cheap. Stick to composited properties for smooth 60fps animations.
| Property |
Triggers Layout? |
Triggers Paint? |
Composited? |
Verdict |
| transform |
No |
No |
Yes |
Use freely — GPU handled, never jank. |
| opacity |
No |
No |
Yes |
Use freely — composited. Combine with transform for entrance effects. |
| filter |
No |
Sometimes |
Often |
Usually fine — test on low-end hardware with complex blur values. |
| background-color · color · box-shadow |
No |
Yes |
No |
Paint-only — acceptable for short transitions, avoid in 60fps loops. |
| width · height · margin · padding · top · left |
Yes |
Yes |
No |
Avoid animating. Causes full reflow. Use transform: scale/translate instead. |
| font-size · line-height |
Yes |
Yes |
No |
Never animate — extremely expensive. Use transform: scale on a wrapper. |
/* Always use transform/opacity equivalents */
/* SLOW: animating layout properties */
.bad { transition: left 0.3s, width 0.3s; }
/* FAST: transform equivalents */
.good { transition: transform 0.3s; }
/* Move: left/top → translateX/translateY */
/* Resize: width/height → scaleX/scaleY (size the element in layout, animate scale) */
/* Show/hide: display → opacity + visibility, or opacity + pointer-events */
/* will-change — promote element to its own layer ahead of time */
.animated-card {
will-change: transform;
/* Use sparingly — each layer consumes GPU memory */
/* Add on hover/focus, remove after animation with JS when possible */
/* Never apply to hundreds of elements simultaneously */
}
5. Scroll-Driven Animations
Scroll-driven animations link animation progress to scroll position — no JavaScript
required. Supported in Chrome 115+ and Firefox 110+ (behind a flag). Two timeline types:
scroll() links to the scroll position of a scroll container, and
view() links to how much of an element is visible in the viewport.
/* scroll() — animate based on how far a container has been scrolled */
@keyframes widen-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
transform-origin: left;
animation: widen-bar linear;
animation-timeline: scroll(root block);
/* root = the root scroll container, block = vertical axis */
}
/* view() — animate based on element's visibility in the viewport */
@keyframes reveal {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
.reveal-on-scroll {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
/* plays while element enters viewport — 0% to 40% of entry */
}
/* Named timelines for cross-element coordination */
.scroll-container {
scroll-timeline-name: --my-scroll;
scroll-timeline-axis: block;
overflow-y: scroll;
}
.driven-element {
animation-timeline: --my-scroll;
/* can be in a different part of the DOM */
}
6. Production Patterns
Loading spinner
/* element is a ring, border-top is coloured */
width: 24px; height: 24px;
border: 3px solid #30363d;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
/* @keyframes spin { to { transform: rotate(360deg); } } */
Skeleton shimmer
background: linear-gradient(
90deg,
#161b22 25%,
#30363d 50%,
#161b22 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
/* from{bg-pos:-200%} to{bg-pos:200%} */
Button press feedback
.btn {
transition: transform 0.1s ease-out;
}
.btn:active {
transform: scale(0.96);
}
/* scale(0.96) on :active = tactile press */
Entrance animation
@keyframes fade-up {
from { opacity:0; transform:translateY(16px); }
to { opacity:1; transform:translateY(0); }
}
.card {
animation: fade-up 0.4s ease-out both;
}
Typewriter effect
@keyframes typing {
from { width: 0; }
to { width: 100%; }
}
.typewriter {
overflow: hidden;
white-space: nowrap;
animation: typing 2s steps(30) forwards,
blink 0.5s step-end infinite;
border-right: 2px solid #58a6ff;
}
Error shake
@keyframes shake {
0%,100%{ transform:translateX(0); }
20% { transform:translateX(-6px); }
40% { transform:translateX(6px); }
60% { transform:translateX(-4px); }
80% { transform:translateX(4px); }
}
.invalid {
animation: shake 0.4s ease both;
}
Read-progress bar
.progress-bar {
position: fixed; top: 0;
width: 100%; height: 3px;
transform-origin: left;
animation: widen-bar linear;
animation-timeline: scroll();
}
Reduced motion reset
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration:
0.01ms !important;
animation-iteration-count:
1 !important;
transition-duration:
0.01ms !important;
}
}
Chapter Summary
| Concept | Key point |
| transition shorthand | property duration easing delay. Put it on the base state so both the forward and reverse transitions run. Never use transition: all — it hits every property including ones you don't want animated. |
| ease-in vs ease-out | ease-in for exits (accelerates away). ease-out for entrances (decelerates to rest). ease-in-out for looping. linear for colour/opacity and spinners. |
| cubic-bezier overshoot | Y values outside 0–1 create overshoot (spring effect). cubic-bezier(0.34, 1.56, 0.64, 1) is a good starting spring. |
| animation-fill-mode | forwards keeps the end state after the animation completes. both applies 0% during delay and forwards at end. Essential for one-shot entrance animations. |
| Negative delay stagger | animation-delay: calc(var(--index) * 60ms) staggers items. Set --index via inline style from HTML or JS. |
| Composited properties | Only animate transform and opacity for guaranteed 60fps. Colour, shadow, size changes trigger paint or layout — acceptable for short transitions, not for continuous loops. |
| will-change | Promotes an element to its own GPU layer before animation starts. Use sparingly — each layer consumes memory. Add before animation, remove after. |
| scroll() timeline | animation-timeline: scroll() links animation progress to the scroll position of the nearest scroll container. No JS needed. |
| view() timeline | animation-timeline: view() links to element visibility. animation-range: entry 0% entry 40% plays the animation as the element enters the viewport. |
| transition-behavior: allow-discrete | Enables transitioning discrete properties like display. Use with @starting-style to define the pre-entry state for enter animations. |
Exercises
- Easing comparison: Create five identical buttons, each with a different easing function (ease, ease-in, ease-out, ease-in-out, and a custom spring cubic-bezier). Give them all
transform: translateX(100px) on hover with a 0.4s duration. Compare how each feels — identify which suits an entrance vs an exit vs a toggle.
- Staggered list entrance: Create an unordered list of 6 items. When the page loads, each item should fade up with a 60ms stagger between them using
animation-delay: calc(var(--i) * 60ms) and inline style="--i: N". Use animation-fill-mode: both to keep items hidden before their animation starts.
- Performance audit: Build an animation that moves a card across the screen using
left. Open DevTools Performance panel, record it, and observe the layout recalculations. Rewrite it using transform: translateX(). Record again and compare the frame times.
- Skeleton loader: Build a card skeleton with three shimmer bars (title, subtitle, body) using a single
@keyframes shimmer animation and staggered animation-delay on each bar so the shimmer sweeps across sequentially.
- Scroll-driven reveal: Create a long page with six sections. Each section heading should fade in and rise as it enters the viewport using
animation-timeline: view() and animation-range: entry 0% entry 30%. Wrap the whole animation in @media (prefers-reduced-motion: no-preference).
Next: Chapter 9 — CSS Variables and Functions.
Custom properties in depth — inheritance, fallbacks, JS interop, and theming patterns.
The full CSS function library: calc(), clamp(), min(), max(), color-mix(),
env(), attr(), and the emerging typed custom properties with @property.