Deep dive: Transitions and Animations

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

PropertyValuesNotes
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

spin
linear infinite
pulse
ease-in-out alternate
bounce
custom easing
shake
error state
fade up
forwards
skeleton
shimmer loader

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

ConceptKey point
transition shorthandproperty 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-outease-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 overshootY values outside 0–1 create overshoot (spring effect). cubic-bezier(0.34, 1.56, 0.64, 1) is a good starting spring.
animation-fill-modeforwards 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 staggeranimation-delay: calc(var(--index) * 60ms) staggers items. Set --index via inline style from HTML or JS.
Composited propertiesOnly animate transform and opacity for guaranteed 60fps. Colour, shadow, size changes trigger paint or layout — acceptable for short transitions, not for continuous loops.
will-changePromotes an element to its own GPU layer before animation starts. Use sparingly — each layer consumes memory. Add before animation, remove after.
scroll() timelineanimation-timeline: scroll() links animation progress to the scroll position of the nearest scroll container. No JS needed.
view() timelineanimation-timeline: view() links to element visibility. animation-range: entry 0% entry 40% plays the animation as the element enters the viewport.
transition-behavior: allow-discreteEnables transitioning discrete properties like display. Use with @starting-style to define the pre-entry state for enter animations.
Exercises
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.