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
| Transitions | Animations | |
|---|---|---|
| Trigger | Requires a state change — a pseudo-class (:hover, :focus) or a class being added/removed | Runs automatically on load (or when a class is applied). No trigger needed. |
| Control | Start state → end state. CSS handles the in-between. | Full timeline via @keyframes — multiple steps, any sequence |
| Looping | Not built-in — loops back on the reverse trigger | Can loop infinitely with animation-iteration-count: infinite |
| Best for | Hover effects, focus rings, show/hide, state changes | Loaders, 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
/* 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;
}
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.
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 */
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.
/* 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.
| Value | Before (delay) | After (finished) |
|---|---|---|
none (default) | Original CSS state | Original CSS state — snaps back |
forwards | Original CSS state | Stays at the to / 100% keyframe |
backwards | Jumps to from / 0% keyframe immediately | Original CSS state |
both | Jumps to from immediately | Stays at to — usually what you want |
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
/* 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
opacity + translateYrotate(360deg) infinitescale + opacity alternatemulti-step translateYtranslateX alternatebackground-positionsteps() opacitybox-shadow alternate/* 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.
/* 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.
- 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.
- width, height, padding, margin — cause full relayout
- top, left, right, bottom — prefer
translateinstead - font-size, line-height — text relayout
- background-color, color, box-shadow — cause repaint (slower than compositing, faster than layout)
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.
/* 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 */
11 — Quick Reference
| Property | Values | Notes |
|---|---|---|
| Transitions | ||
transition | property duration timing delay | Shorthand — comma-separate for multiple |
transition-property | transform, opacity, all… | Which property to watch |
transition-duration | 0.3s, 200ms | How long the transition takes |
transition-timing-function | ease · linear · ease-in · ease-out · ease-in-out · cubic-bezier() | The speed curve |
transition-delay | 0s, 0.1s | Wait before starting |
| Animations | ||
@keyframes name | from/to or 0%–100% stops | Define the animation timeline |
animation | name duration timing delay count direction fill-mode | Shorthand — comma-separate for multiple |
animation-duration | 0.5s, 1200ms | One cycle length |
animation-iteration-count | number or infinite | How many times to play |
animation-direction | normal · reverse · alternate · alternate-reverse | Forward/backward/bouncing |
animation-fill-mode | none · forwards · backwards · both | State before/after; use both for entrances |
animation-play-state | running · paused | Toggle 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-origin | center · 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.
::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.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.<button class="fancy-btn"><span>Get started</span></button>
.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);
}
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.@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%; }
nth-child selectors to set the delays.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.@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;
}
}
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..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.<div class="icon-wrapper">
<span class="icon">🔔</span>
<span class="badge" id="badge">3</span>
</div>
@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;
}
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.