Every concept in this chapter โ transitions, transforms, keyframes, timing functions, and performance โ comes together in real UI components. In this final lesson you will build six production-quality animated components from scratch: a page loader, an animated button with success state, a slide-over panel, a scroll-triggered reveal, a morphing menu icon, and a complete micro-interaction library. These patterns are used daily in professional frontend development.
Animation Design Principles
| Principle | Rule of Thumb | Example |
|---|---|---|
| Duration | UI feedback: 100โ300ms; Transitions: 200โ500ms; Decorative: up to 2s | Button press: 150ms; Modal open: 350ms; Page loader: 1.5s loop |
| Easing | ease-out for things arriving; ease-in for things leaving | Dropdown: ease-out open, ease-in close |
| Purpose | Every animation must serve a function โ feedback, orientation, or delight | Shake = error; Pulse = new notification; Spinner = loading |
| Restraint | Animate one or two things at a time โ not everything at once | Card hover: translateY + box-shadow only, not 6 properties |
| Direction | Objects should enter from the direction they logically come from | Side panel slides from the side; dropdown falls from above |
Animation Timing Cheat Sheet
| Component | Duration | Easing | Notes |
|---|---|---|---|
| Button click feedback | 100โ150ms | ease-out | Fast โ feels like physical response |
| Hover state | 150โ250ms | ease | Snappy but not jarring |
| Dropdown / tooltip | 200โ300ms | ease-out | Quick reveal, gentle landing |
| Modal / dialog | 300โ400ms | cubic-bezier spring | Bouncy entry adds personality |
| Page transition | 400โ600ms | ease-in-out | Smooth โ too fast feels like a glitch |
| Loader / spinner | 0.8โ1.5s loop | linear or ease-in-out | Predictable rhythm = calm |
spring() / Bouncy Cubic-Bezier Values
| Effect | cubic-bezier | Use For |
|---|---|---|
| Gentle bounce entry | cubic-bezier(0.34, 1.56, 0.64, 1) |
Modals, tooltips, notification toasts |
| Overshoot and settle | cubic-bezier(0.175, 0.885, 0.32, 1.275) |
Popover menus, dropdowns |
| Material Design standard | cubic-bezier(0.4, 0, 0.2, 1) |
General-purpose UI transitions |
| Material Design decelerate | cubic-bezier(0, 0, 0.2, 1) |
Elements entering the screen |
| Material Design accelerate | cubic-bezier(0.4, 0, 1, 1) |
Elements leaving the screen |
transition and animation values on interactive elements. Real-world values from production products are the fastest way to develop an eye for timing and easing that feels professional.--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1) in :root means you write the bouncy easing once and reference it everywhere. This makes it trivial to globally swap the “personality” of your animation system by changing one variable.Basic Example โ Six Production Animation Patterns
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<title>Real-World Animation Patterns</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 100%; }
body { font-family: 'Inter', system-ui, sans-serif; background: #f8fafc; padding: 40px 32px; }
h3 { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; margin: 40px 0 16px; }
.row { display: flex; flex-wrap: wrap; gap: 20px; align-items: center; }
/* โโ 1. Morphing Hamburger to X โโ */
.menu-btn {
background: none; border: none; cursor: pointer;
width: 40px; height: 40px;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 6px;
padding: 8px;
}
.menu-line {
display: block;
width: 22px; height: 2px;
background: #0f172a;
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: center;
}
.menu-btn.open .menu-line:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.menu-btn.open .menu-line:nth-child(2) { opacity: 0; transform: scaleX(0); }
.menu-btn.open .menu-line:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
/* โโ 2. Button with success state โโ */
@keyframes successPop {
0% { transform: scale(0.8); opacity: 0; }
60% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
.submit-btn {
padding: 12px 28px;
background: #4f46e5;
color: white;
border: none;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background-color 0.2s ease, transform 0.15s ease;
min-width: 140px;
position: relative;
overflow: hidden;
}
.submit-btn:hover { background: #4338ca; }
.submit-btn:active { transform: scale(0.97); }
.submit-btn.loading {
background: #6366f1;
pointer-events: none;
}
.submit-btn.success {
background: #10b981;
pointer-events: none;
}
.btn-text { transition: opacity 0.15s ease; }
.btn-icon {
position: absolute;
inset: 0; display: flex; align-items: center; justify-content: center;
opacity: 0;
}
.submit-btn.loading .btn-text { opacity: 0; }
.submit-btn.loading .btn-icon.loading-icon { opacity: 1; }
.submit-btn.success .btn-text { opacity: 0; }
.submit-btn.success .btn-icon.success-icon {
opacity: 1;
animation: successPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
/* โโ 3. Slide-Over Panel โโ */
.panel-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
z-index: 100;
}
.panel-overlay.open { opacity: 1; pointer-events: auto; }
.slide-panel {
position: fixed;
top: 0; right: 0; bottom: 0;
width: min(400px, 90vw);
background: white;
box-shadow: -8px 0 40px rgba(0,0,0,0.12);
transform: translateX(100%);
transition: transform 0.35s cubic-bezier(0, 0, 0.2, 1);
z-index: 101;
padding: 24px;
overflow-y: auto;
}
.panel-overlay.open .slide-panel {
transform: translateX(0);
}
.panel-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 24px;
}
.panel-title { font-size: 1rem; font-weight: 700; color: #0f172a; }
.panel-close {
background: #f1f5f9; border: none; cursor: pointer;
width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; color: #64748b;
transition: background 0.15s;
}
.panel-close:hover { background: #e2e8f0; }
/* โโ 4. Scroll-reveal cards (JS adds .visible class) โโ */
.reveal-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 20px;
width: 160px;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease-out, transform 0.5s ease-out;
}
.reveal-card.visible {
opacity: 1;
transform: translateY(0);
}
.reveal-card:nth-child(2) { transition-delay: 0.1s; }
.reveal-card:nth-child(3) { transition-delay: 0.2s; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.reveal-card { opacity: 1; transform: none; }
}
/* Spinner for loading state */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner-sm {
width: 18px; height: 18px;
border: 2.5px solid rgba(255,255,255,0.4);
border-top-color: white;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
</style>
</head>
<body>
<h3>1 โ Morphing Menu Icon โ click to toggle</h3>
<button class="menu-btn" id="menuBtn" aria-label="Toggle menu" aria-expanded="false">
<span class="menu-line"></span>
<span class="menu-line"></span>
<span class="menu-line"></span>
</button>
<h3>2 โ Button with Loading + Success State โ click me</h3>
<button class="submit-btn" id="submitBtn">
<span class="btn-text">Save changes</span>
<span class="btn-icon loading-icon"><span class="spinner-sm"></span></span>
<span class="btn-icon success-icon">✓</span>
</button>
<h3>3 โ Slide-Over Panel โ click to open</h3>
<button onclick="document.getElementById('overlay').classList.add('open')"
style="padding:10px 20px;background:#0f172a;color:white;border:none;border-radius:8px;font-size:0.875rem;font-weight:600;cursor:pointer;font-family:inherit">
Open Panel
</button>
<div class="panel-overlay" id="overlay" onclick="if(event.target===this)this.classList.remove('open')">
<aside class="slide-panel">
<div class="panel-header">
<p class="panel-title">Panel Title</p>
<button class="panel-close" onclick="document.getElementById('overlay').classList.remove('open')">×</button>
</div>
<p style="font-size:0.875rem;color:#64748b;line-height:1.6">This panel slides in from the right using <code>transform: translateX(100%)</code> โ <code>translateX(0)</code> with a Material decelerate easing. Click outside or the X to close.</p>
</aside>
</div>
<h3>4 โ Scroll-Reveal Cards โ add .visible to trigger</h3>
<div class="row" id="revealRow">
<div class="reveal-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card A</p><p style="font-size:0.8rem;color:#64748b">Staggered reveal</p></div>
<div class="reveal-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card B</p><p style="font-size:0.8rem;color:#64748b">100ms delay</p></div>
<div class="reveal-card"><p style="font-weight:700;color:#0f172a;margin-bottom:4px">Card C</p><p style="font-size:0.8rem;color:#64748b">200ms delay</p></div>
</div>
<button onclick="document.querySelectorAll('.reveal-card').forEach(c=>c.classList.toggle('visible'))"
style="margin-top:12px;padding:8px 18px;background:#f1f5f9;color:#0f172a;border:1.5px solid #e2e8f0;border-radius:8px;font-size:0.8rem;font-weight:600;cursor:pointer;font-family:inherit">
Toggle .visible
</button>
<script>
// Menu toggle
document.getElementById('menuBtn').addEventListener('click', function() {
this.classList.toggle('open');
this.setAttribute('aria-expanded', this.classList.contains('open'));
});
// Submit button states
const btn = document.getElementById('submitBtn');
btn.addEventListener('click', () => {
btn.classList.add('loading');
setTimeout(() => {
btn.classList.remove('loading');
btn.classList.add('success');
setTimeout(() => {
btn.classList.remove('success');
}, 2000);
}, 1500);
});
</script>
</body>
</html>
How It Works
Step 1 โ Hamburger Morphs via Transform Chains
The three lines morph into an X by moving lines 1 and 3 to the centre (translateY) and then rotating them 45 degrees. Line 2 fades out and collapses with scaleX(0). All transforms are on the same transition with identical duration โ they animate simultaneously, creating a smooth morph rather than a sequential switch.
Step 2 โ Button States Use Class Toggling
The button has three visual states (idle, loading, success) controlled by CSS classes added via JavaScript. The loading spinner and success checkmark are always in the DOM โ hidden with opacity: 0. Transitioning opacity between states is instant (0.15s), while the success icon gets a spring animation via successPop keyframes.
Step 3 โ Slide Panel Uses Material Easing
The panel enters with cubic-bezier(0, 0, 0.2, 1) โ Material Design’s decelerate curve. It starts at full speed (0,0) and decelerates to rest (0.2, 1), like a physical object sliding in and slowing to a stop. The overlay fade is separate with a standard ease so both can complete at slightly different rates.
Step 4 โ Scroll Reveal Is Just CSS + One Line of JS
Cards start at opacity: 0; transform: translateY(20px). JavaScript (typically an IntersectionObserver) adds the .visible class when the card enters the viewport โ CSS handles the rest. Stagger comes from transition-delay on nth-child selectors. The prefers-reduced-motion reset ensures cards appear immediately for users who need it.
Step 5 โ Reduced Motion Handles All Patterns
The global prefers-reduced-motion: reduce block at the bottom sets all durations to 0.01ms. The hamburger still morphs (instantly), buttons still change colour (instantly), the panel still opens (instantly) โ functionality is preserved but all motion is eliminated.
Real-World Example: Complete Micro-Interaction Library
/* micro-interactions.css */
:root {
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-enter: cubic-bezier(0, 0, 0.2, 1);
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 150ms;
--duration-base: 250ms;
--duration-slow: 400ms;
}
/* โโ Press feedback โโ */
.press-feedback { transition: transform var(--duration-fast) var(--ease-exit); }
.press-feedback:active { transform: scale(0.96); }
/* โโ Ripple on click โโ */
.ripple { position: relative; overflow: hidden; }
.ripple::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
transform: scale(0);
opacity: 0;
border-radius: inherit;
transition: transform 0.4s ease-out, opacity 0.4s ease-out;
}
.ripple:active::after { transform: scale(2.5); opacity: 1; transition: none; }
/* โโ Input focus ring โโ */
.input-focus {
border: 1.5px solid #e2e8f0;
border-radius: 8px;
outline: none;
transition: border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease;
}
.input-focus:focus {
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.12);
}
/* โโ Tooltip appear โโ */
.tooltip-wrap { position: relative; display: inline-block; }
.tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px) scale(0.95);
background: #0f172a;
color: white;
font-size: 0.75rem;
font-weight: 500;
padding: 6px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity var(--duration-fast) var(--ease-enter),
transform var(--duration-fast) var(--ease-enter);
}
.tooltip-wrap:hover .tooltip,
.tooltip-wrap:focus-within .tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
/* โโ Checkbox bounce โโ */
@keyframes checkBounce {
0% { transform: scale(0); }
60% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.fancy-checkbox input:checked + .checkmark {
animation: checkBounce 0.3s var(--ease-spring) both;
}
/* โโ Number counter roll โโ */
@keyframes rollIn {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.counter-digit { overflow: hidden; display: inline-block; }
.counter-digit span { display: block; animation: rollIn 0.4s var(--ease-spring) both; }
/* โโ Skeleton pulse โโ */
@keyframes skeletonPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton { animation: skeletonPulse 1.8s ease-in-out infinite; background: #e2e8f0; border-radius: 6px; }
/* โโ Global a11y reset โโ */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Common Mistakes
Mistake 1 โ Animating too many things simultaneously
โ Wrong โ six properties changing at once creates visual chaos:
.card:hover {
transform: scale(1.08) rotate(2deg);
background: #4f46e5;
color: white;
border-radius: 20px;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
padding: 32px; /* triggers layout */
}
โ Correct โ animate 1โ2 properties for clean, purposeful motion:
.card:hover {
transform: translateY(-6px);
box-shadow: 0 16px 32px rgba(0,0,0,0.1);
}
Mistake 2 โ Using animation for state that CSS transitions can handle
โ Wrong โ @keyframes overkill for a simple hover state change:
@keyframes btnHover {
from { background: #4f46e5; }
to { background: #4338ca; }
}
.btn:hover { animation: btnHover 0.3s ease forwards; }
โ Correct โ use transition for two-state changes:
.btn { background: #4f46e5; transition: background-color 0.3s ease; }
.btn:hover { background: #4338ca; }
Mistake 3 โ Forgetting aria-expanded on interactive toggle elements
โ Wrong โ screen readers cannot report the open/closed state of the menu:
<button class="menu-btn" onclick="this.classList.toggle('open')">
โ Correct โ update aria-expanded to match the visual state:
<button class="menu-btn" aria-expanded="false" onclick="
this.classList.toggle('open');
this.setAttribute('aria-expanded', this.classList.contains('open'));
">
Quick Reference
| Pattern | CSS Technique | JS Required? |
|---|---|---|
| Hamburger to X morph | transform on spans via .open class | Toggle .open class + aria-expanded |
| Button loading state | Class toggling; spinner via @keyframes spin | Add/remove .loading, .success classes |
| Slide-over panel | translateX(100%) โ translateX(0) | Toggle .open class on overlay |
| Scroll reveal | opacity + translateY โ visible state | IntersectionObserver adds .visible |
| Tooltip | opacity + scale via :hover / :focus-within | None โ pure CSS |
| Micro-interactions | Custom properties for easing tokens | Class toggle or :active/:hover |