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
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); } } */
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.