Transitions and Simple Animations

✨ Chapter 10 — Transitions and Animations

Motion brings interfaces to life. A button that smoothly changes colour on hover, a modal that slides in from the top, a loading spinner — these small details signal that an interface is responsive and polished. CSS provides two mechanisms for motion: transitions, which interpolate a property between two states in response to a trigger (like :hover); and animations, which run independently on a timeline you define with @keyframes. Used with care they guide attention and improve usability; overused they become noise.

1 — Transitions vs Animations

TransitionsAnimations
TriggerRequires a state change — a pseudo-class (:hover, :focus) or a class being added/removedRuns automatically on load (or when a class is applied). No trigger needed.
ControlStart state → end state. CSS handles the in-between.Full timeline via @keyframes — multiple steps, any sequence
LoopingNot built-in — loops back on the reverse triggerCan loop infinitely with animation-iteration-count: infinite
Best forHover effects, focus rings, show/hide, state changesLoaders, skeletons, entrance animations, decorative motion

2 — CSS Transitions

A transition tells the browser: "when this property changes, don't snap — glide over this duration using this easing curve". The four sub-properties:

  • transition-property — which CSS property to animate (background, transform, all…)
  • transition-duration — how long the transition takes (0.3s, 200ms)
  • transition-timing-function — the easing curve (ease, linear, cubic-bezier(…))
  • transition-delay — how long to wait before starting
🎨 CSS — transition syntax
/* Individual properties */ .box { transition-property: background-color; transition-duration: 0.3s; transition-timing-function: ease; transition-delay: 0s; } /* Shorthand: property duration timing-function delay */ .box { transition: background-color 0.3s ease; } /* Multiple transitions — comma-separated */ .card { transition: transform 0.25s ease, box-shadow 0.25s ease, background 0.15s ease; } /* transition: all — convenient but less efficient */ .btn { transition: all 0.2s ease; /* watches every property — avoid on complex elements */ } /* With delay — useful for staggered hover effects */ .tooltip { opacity: 0; transition: opacity 0.2s ease 0.4s; /* wait 400ms before fading in */ } .parent:hover .tooltip { opacity: 1; }
Live demos — hover each button to see different transition effects
Always put the transition on the base state, not the hover state. If you put transition only on :hover, the animation plays on hover-in but snaps back instantly on hover-out. Put it on the element itself to get smooth transitions in both directions.

3 — Timing Functions (Easing Curves)

The timing function controls the rate of change over the transition duration — whether it accelerates, decelerates, or moves at a constant speed. This has a huge impact on the feel of the motion.

Hover each row to watch the bars animate — notice how differently they feel
linear
Constant speed — feels mechanical
ease
Slow start, fast middle, slow end — the default
ease-in
Slow start, fast end — feels like acceleration
ease-out
Fast start, slow end — feels like deceleration
ease-in-out
Slow both ends — smooth and natural
🎨 CSS — timing functions including cubic-bezier
transition-timing-function: linear; transition-timing-function: ease; /* default */ transition-timing-function: ease-in; transition-timing-function: ease-out; transition-timing-function: ease-in-out; /* cubic-bezier(x1, y1, x2, y2) — define your own curve */ transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* ↑ overshoot (spring) effect — y values can exceed 0–1 */ /* steps() — frame-by-frame, no interpolation */ transition-timing-function: steps(4, end); /* Good for sprite-sheet animations — jumps in 4 discrete steps */
Use easings.net or the DevTools cubic-bezier editor to design custom curves visually. The rule of thumb: for things entering the screen use ease-out (decelerates into position); for things leaving the screen use ease-in (accelerates away). For UI state changes, ease or ease-in-out work well.

4 — CSS Animations and @keyframes

Animations are defined in two parts: the @keyframes rule, which describes what changes at each point in time, and the animation properties on an element, which describe how to play those keyframes.

🎨 CSS — @keyframes and animation properties
/* Define the keyframes — named sequence of states */ @keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } /* Percentage stops — more than two states */ @keyframes bounce { 0% { transform: translateY(0); } 30% { transform: translateY(-20px); } 60% { transform: translateY(-8px); } 100% { transform: translateY(0); } } /* Apply the animation — individual properties */ .element { animation-name: fadeIn; animation-duration: 0.6s; animation-timing-function: ease-out; animation-delay: 0s; animation-iteration-count: 1; /* or infinite */ animation-direction: normal; animation-fill-mode: both; animation-play-state: running; } /* Shorthand: name duration timing-function delay iterations direction fill-mode */ .element { animation: fadeIn 0.6s ease-out both; animation: spin 1s linear infinite; animation: bounce 0.8s ease 0.2s 3; /* play 3 times, 200ms delay */ } /* Multiple animations — comma-separated */ .element { animation: fadeIn 0.5s ease both, glowPulse 2s ease-in-out 0.5s infinite; }

5 — animation-fill-mode

fill-mode controls what state the element is in before the animation starts (during the delay) and after it finishes. Without it, an element snaps back to its original CSS state the moment an animation ends.

ValueBefore (delay)After (finished)
none (default)Original CSS stateOriginal CSS state — snaps back
forwardsOriginal CSS stateStays at the to / 100% keyframe
backwardsJumps to from / 0% keyframe immediatelyOriginal CSS state
bothJumps to from immediatelyStays at to — usually what you want
💡 Default to animation-fill-mode: both. For entrance animations (fade-in, slide-in), both means the element starts invisible/offscreen immediately and stays in its final position after arriving. Without it, you'll see a flash of the visible element before the animation starts, or it will snap back to invisible after landing.

6 — animation-direction and animation-play-state

🎨 CSS — direction and play-state
/* animation-direction */ animation-direction: normal; /* default — plays forward each iteration */ animation-direction: reverse; /* plays backward each iteration */ animation-direction: alternate; /* forward, then backward, then forward… */ animation-direction: alternate-reverse; /* backward, then forward, then backward… */ /* alternate is great for pulsing/breathing effects — no hard restart */ @keyframes breathe { from { transform: scale(1); } to { transform: scale(1.06); } } .icon { animation: breathe 2s ease-in-out alternate infinite; } /* animation-play-state — pause/resume with CSS or JavaScript */ .spinner { animation: spin 1s linear infinite; animation-play-state: running; } /* Pause on hover */ .spinner:hover { animation-play-state: paused; } /* Or toggle with JavaScript */ /* el.style.animationPlayState = 'paused'; */

7 — Common Animation Patterns

Live animations — all running in the page right now
Fade + rise
opacity + translateY
Loading spinner
rotate(360deg) infinite
Pulse / heartbeat
scale + opacity alternate
Bounce
multi-step translateY
Slide in
translateX alternate
Skeleton shimmer
background-position
Hello
Cursor blink
steps() opacity
Glow pulse
box-shadow alternate
🎨 CSS — The keyframes behind these examples
/* Spinning loader */ @keyframes spin { to { transform: rotate(360deg); } } .spinner { width: 40px; height: 40px; border: 4px solid #2a2d3a; border-top-color: #4f8ef7; border-radius: 50%; animation: spin 0.9s linear infinite; } /* Skeleton screen shimmer */ @keyframes shimmer { 0% { background-position: -200% center; } 100% { background-position: 200% center; } } .skeleton { background: linear-gradient(90deg, #1a1d27 25%, #2a2d3a 50%, #1a1d27 75%); background-size: 200% auto; animation: shimmer 1.6s linear infinite; } /* Page entrance — staggered children */ @keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } /* Stagger using nth-child delays */ .card { animation: fadeUp 0.5s ease both; } .card:nth-child(2) { animation-delay: 0.1s; } .card:nth-child(3) { animation-delay: 0.2s; } .card:nth-child(4) { animation-delay: 0.3s; }

8 — transform — The Animation Workhorse

The transform property is used in the vast majority of animations. It does not affect document flow at all — it only changes how the element is painted on screen, making it the most GPU-friendly property to animate.

🎨 CSS — Common transform functions
/* Translation — move without affecting layout */ transform: translateX(20px); /* move right */ transform: translateY(-10px); /* move up */ transform: translate(10px, -5px); /* X and Y together */ transform: translate(-50%, -50%); /* % of the element itself (centering trick) */ /* Scaling */ transform: scale(1.1); /* 10% larger */ transform: scaleX(0); /* collapse horizontally */ /* Rotation */ transform: rotate(45deg); /* clockwise 45° */ transform: rotate(-90deg); /* counter-clockwise 90° */ /* Skew */ transform: skewX(10deg); /* Combining — right to left order of application */ transform: translate(-50%, -50%) rotate(45deg) scale(1.2); /* transform-origin — pivot point for rotate/scale */ transform-origin: center; /* default */ transform-origin: top left; /* fold from corner */ transform-origin: bottom center; /* grow upward from base */

9 — Performance: What to Animate

Not all CSS properties are equal when animated. The browser rendering pipeline has three expensive steps — layout, paint, and composite. Animating a property that triggers layout forces the browser to recalculate the position of every element on the page each frame — this is slow. Animating properties that only trigger compositing lets the GPU handle the work without touching the CPU at all — this is fast and smooth.

✓ GPU-friendly — animate freely
  • transform — translate, scale, rotate, skew
  • opacity — fade in/out
  • filter — blur, brightness (modern browsers)
  • These only trigger compositing — the GPU handles it, 60fps is achievable.
✗ Layout-triggering — use with care
  • width, height, padding, margin — cause full relayout
  • top, left, right, bottom — prefer translate instead
  • font-size, line-height — text relayout
  • background-color, color, box-shadow — cause repaint (slower than compositing, faster than layout)
💡 The golden rule: animate transform and opacity whenever possible. To move something, use transform: translate() rather than changing left/top. To show/hide, transition opacity (combined with visibility or a class toggle). Add will-change: transform to elements you know will animate — this hints to the browser to promote the element to its own GPU layer in advance.

10 — prefers-reduced-motion

Some users experience dizziness, nausea, or seizures from motion on screen. Operating systems provide a "reduce motion" setting, and CSS can detect it with the prefers-reduced-motion media query. Respecting this setting is an accessibility requirement for any production site.

🎨 CSS — Respecting the user's motion preference
/* Default — motion enabled */ .card { animation: fadeUp 0.5s ease both; transition: transform 0.25s ease; } /* Disable animations for users who prefer it */ @media (prefers-reduced-motion: reduce) { /* Option 1: turn everything off globally */ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } /* Option 2: provide a safe alternative animation */ .card { animation: none; /* remove entrance animation */ opacity: 1; /* ensure it's visible */ transition: opacity 0.1s; /* instant fade still OK */ } } /* Test in Chrome: DevTools → Rendering → Emulate CSS media feature: prefers-reduced-motion → reduce */
The WCAG 2.1 guidelines (Success Criterion 2.3.3) require that animations triggered by interaction can be disabled. Looping or large-scale animations that play automatically must also be stoppable. The global reset approach (Option 1) is the safest and most common defensive baseline for a production site.

11 — Quick Reference

PropertyValuesNotes
Transitions
transitionproperty duration timing delayShorthand — comma-separate for multiple
transition-propertytransform, opacity, allWhich property to watch
transition-duration0.3s, 200msHow long the transition takes
transition-timing-functionease · linear · ease-in · ease-out · ease-in-out · cubic-bezier()The speed curve
transition-delay0s, 0.1sWait before starting
Animations
@keyframes namefrom/to or 0%–100% stopsDefine the animation timeline
animationname duration timing delay count direction fill-modeShorthand — comma-separate for multiple
animation-duration0.5s, 1200msOne cycle length
animation-iteration-countnumber or infiniteHow many times to play
animation-directionnormal · reverse · alternate · alternate-reverseForward/backward/bouncing
animation-fill-modenone · forwards · backwards · bothState before/after; use both for entrances
animation-play-staterunning · pausedToggle playback — great for hover-pause
Transform functions
translate(x, y)translate(20px, -10px)Move — GPU-composited, no layout impact
scale(n)scale(1.1), scaleX(0)Resize from transform-origin
rotate(deg)rotate(45deg), rotate(-0.5turn)Spin around transform-origin
skew(x, y)skewX(15deg)Slant
transform-origincenter · top left · 50% 0%Pivot point for scale and rotate

✏️ Exercises

These exercises combine transitions and animations with the layout and positioning from previous chapters. All motion should be tested in a real browser — DevTools lets you slow animations to 0.1× speed in the Animations panel, which is invaluable for debugging.

Exercise 1
Create an interactive button with a layered hover effect. The button should have three distinct changes on hover, each transitioning independently: (1) the background colour fills from left to right using a ::before pseudo-element sliding in; (2) the text colour changes to contrast with the new background; (3) the button lifts slightly with transform: translateY(-2px) and gains a coloured drop shadow. All transitions should be smooth and return to normal when the cursor leaves.
Hint: use overflow: hidden on the button and a ::before pseudo-element with position: absolute; transform: translateX(-100%) at rest, transitioning to translateX(0) on hover. Put the text in a <span> with position: relative; z-index: 1 so it sits above the sliding background layer.
HTML
<button class="fancy-btn"><span>Get started</span></button>
CSS
.fancy-btn { position: relative; overflow: hidden; padding: 12px 28px; border: 2px solid #4f8ef7; border-radius:6px; background: transparent; color: #4f8ef7; font-size: 1rem; font-weight: 700; font-family: system-ui, sans-serif; cursor: pointer; transition: color 0.25s ease, transform 0.2s ease, box-shadow 0.2s ease; } /* Sliding fill layer */ .fancy-btn::before { content: ''; position: absolute; inset: 0; background: #4f8ef7; transform: translateX(-100%); transition: transform 0.3s ease; z-index: 0; } /* Text above the fill layer */ .fancy-btn span { position: relative; z-index: 1; } /* Hover state */ .fancy-btn:hover { color: #fff; transform: translateY(-2px); box-shadow: 0 8px 20px rgba(79, 142, 247, 0.4); } .fancy-btn:hover::before { transform: translateX(0); }
Exercise 2
Build a loading spinner and a skeleton screen side by side. The spinner should be a circular border element where only one quarter of the border is coloured, rotating continuously. The skeleton screen should simulate a card being loaded — two lines of "text" (grey bars) and an "image" area, all shimmering from left to right with a moving highlight. Both should run indefinitely. Wrap both in a card-shaped container.
Hint: the spinner uses border-top-color for the coloured segment and animation: spin 1s linear infinite. The shimmer uses background: linear-gradient(90deg, …) with background-size: 200% and animates background-position from -200% center to 200% center.
CSS
@keyframes spin { to { transform: rotate(360deg); } } @keyframes shimmer { 0% { background-position: -200% center; } 100% { background-position: 200% center; } } .spinner { width: 48px; height: 48px; border: 5px solid #2a2d3a; border-top-color:#4f8ef7; border-radius: 50%; animation: spin 0.9s linear infinite; } /* Reusable shimmer class */ .skel { background: linear-gradient(90deg, #1a1d27 25%, #2a2d3a 50%, #1a1d27 75%); background-size: 200% auto; border-radius: 4px; animation: shimmer 1.6s linear infinite; } .skel-image { height: 120px; margin-bottom: 12px; } .skel-line { height: 12px; margin-bottom: 8px; } .skel-line.short { width: 60%; }
Exercise 3
Create a staggered card entrance animation. Build a row of four cards. When the page loads, the cards should fade up into position one after another — card 1 first, card 2 a fraction of a second later, card 3 later still, and card 4 last. Before the animation runs, the cards should be invisible and below their final position. After the animation ends, they should stay in their final visible position. Use nth-child selectors to set the delays.
Hint: define a fadeUp keyframe that animates from opacity: 0; transform: translateY(24px) to opacity: 1; transform: translateY(0). Apply animation: fadeUp 0.5s ease both to all cards, then use .card:nth-child(2) { animation-delay: 0.1s; } and so on. fill-mode: both ensures cards start hidden before their delay expires.
CSS
@keyframes fadeUp { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } } .card-row { display: flex; gap: 16px; } .card { flex: 1; padding: 20px; background:#1a1d27; border: 1px solid #2a2d3a; border-radius:8px; font-family:system-ui, sans-serif; /* Both: starts hidden before delay, stays visible after */ animation: fadeUp 0.5s ease both; } /* Stagger each card's start time */ .card:nth-child(1) { animation-delay: 0s; } .card:nth-child(2) { animation-delay: 0.1s; } .card:nth-child(3) { animation-delay: 0.2s; } .card:nth-child(4) { animation-delay: 0.3s; } /* Always add reduced-motion support */ @media (prefers-reduced-motion: reduce) { .card { animation: none; opacity: 1; } }
Exercise 4
Build a notification badge that draws attention. The badge should float in the top-right corner of an icon using absolute positioning (Chapter 7). When a count is shown (not zero), the badge pulses — it gently scales up and back down, repeating forever with alternate direction. Hovering the parent icon should pause the animation (use animation-play-state: paused). The badge should disappear (using JavaScript to add a class) when clicked, transitioning to opacity: 0; transform: scale(0) over 0.2 seconds before display: none is applied.
Hint: for the click-to-dismiss, add a class like .dismissed that sets opacity: 0; transform: scale(0); transition: …. Listen for the transitionend event on the badge to then set display: none after the transition completes.
HTML
<div class="icon-wrapper"> <span class="icon">🔔</span> <span class="badge" id="badge">3</span> </div>
CSS
@keyframes badgePulse { from { transform: scale(1); } to { transform: scale(1.25); } } .icon-wrapper { position: relative; display: inline-block; cursor: pointer; font-size: 2rem; } .badge { position: absolute; top: -6px; right: -6px; background: #f08080; color: #fff; font-size: 0.65rem; font-weight: 700; font-family: system-ui, sans-serif; min-width: 20px; height: 20px; border-radius:999px; display: flex; align-items: center; justify-content:center; animation: badgePulse 0.8s ease-in-out alternate infinite; transition: opacity 0.2s ease, transform 0.2s ease; cursor: pointer; } /* Pause pulse when the icon is hovered */ .icon-wrapper:hover .badge { animation-play-state: paused; } /* Dismiss transition */ .badge.dismissed { opacity: 0; transform: scale(0); animation: none; }
JavaScript
const badge = document.getElementById('badge'); badge.addEventListener('click', () => { badge.classList.add('dismissed'); badge.addEventListener('transitionend', () => { badge.style.display = 'none'; }, { once: true }); });

The { once: true } option on the event listener automatically removes it after it fires once, preventing memory leaks. The transitionend event fires after the CSS transition completes — only then do we apply display: none, so the visual transition is not cut short.