| name | microinteraction-library |
| description | This skill should be used when the user needs production-ready animation patterns for specific UI states or feedback moments. Trigger when the user mentions "loading state", "skeleton screen", "loading spinner", "error animation", "error shake", "success animation", "success checkmark", "form feedback", "button loading state", "checkbox animation", "toggle animation", "notification badge", "like animation", "copy to clipboard feedback", "progress indicator", "toast notification animation", or wants to add "polish" or "juice" to individual UI components. Also trigger when the user asks how to animate specific component states (empty state, error state, success state, pending state). |
Microinteraction Pattern Library
Microinteractions define product feel and polish. Each one is a small, specific, meaningful moment of feedback — a confirmation that the product is alive and responding. The goal is not novelty but clarity and delight in equal measure.
Design principle: Every microinteraction must earn cognitive attention. Animations that are too slow, too large, or too frequent become visual noise. The best microinteractions are noticed when absent, not when present.
Pattern Catalog
Loading States
Skeleton Screen (preferred over spinners)
Use when: content shape is known ahead of load. Reduces disorientation by showing the incoming structure.
function SkeletonCard() {
return (
<div className="skeleton-card">
<div className="skeleton-avatar" />
<div className="skeleton-lines">
<div className="skeleton-line skeleton-line--wide" />
<div className="skeleton-line skeleton-line--medium" />
<div className="skeleton-line skeleton-line--narrow" />
</div>
</div>
);
}
.skeleton-line,
.skeleton-avatar {
background: #e0e0e0;
border-radius: 4px;
animation: skeletonPulse 1.5s ease-in-out infinite;
}
.skeleton-line:nth-child(2) { animation-delay: 150ms; }
.skeleton-line:nth-child(3) { animation-delay: 300ms; }
@keyframes skeletonPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.skeleton-shimmer {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: shimmerSlide 1.5s infinite;
}
@keyframes shimmerSlide {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
Button Loading State
Transition label → spinner on async action; return to label on completion:
function AsyncButton({ onClick, children }) {
const [state, setState] = useState<"idle" | "loading" | "success" | "error">("idle");
async function handleClick() {
setState("loading");
try {
await onClick();
setState("success");
setTimeout(() => setState("idle"), 2000);
} catch {
setState("error");
setTimeout(() => setState("idle"), 1500);
}
}
return (
<motion.button
onClick={handleClick}
disabled={state === "loading"}
animate={{
scale: state === "error" ? [1, 0.97, 1] : 1,
}}
>
<AnimatePresence mode="wait">
{state === "idle" && (
<motion.span key="label"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}>
{children}
</motion.span>
)}
{state === "loading" && (
<motion.span key="spinner"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<Spinner />
</motion.span>
)}
{state === "success" && (
<motion.span key="check"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}>
✓
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
}
Feedback Animations
Error Shake
@keyframes errorShake {
0% { transform: translateX(0); }
15% { transform: translateX(-8px); }
30% { transform: translateX(8px); }
45% { transform: translateX(-6px); }
60% { transform: translateX(6px); }
75% { transform: translateX(-4px); }
90% { transform: translateX(4px); }
100% { transform: translateX(0); }
}
.field--error {
animation: errorShake 400ms ease-in-out;
border-color: var(--color-error);
}
In React with Framer Motion:
<motion.input
animate={hasError ? {
x: [0, -8, 8, -6, 6, -4, 4, 0],
transition: { duration: 0.4 }
} : {}}
/>
Success Checkmark Draw (SVG)
function SuccessCheck() {
return (
<motion.svg viewBox="0 0 52 52" width={52} height={52}>
<motion.circle
cx="26" cy="26" r="25" fill="none" stroke="var(--color-success)"
strokeWidth="2"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, ease: "easeOut" }}
/>
<motion.path
fill="none" stroke="var(--color-success)"
strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
d="M14 27 L22 35 L38 19"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: "easeOut", delay: 0.3 }}
/>
</motion.svg>
);
}
Toggle & Checkbox
Toggle — must feel physical, like a real switch:
function Toggle({ checked, onChange }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`toggle ${checked ? "toggle--on" : ""}`}
>
<motion.span
className="toggle__thumb"
layout
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
</button>
);
}
.toggle {
width: 44px; height: 24px;
border-radius: 12px;
background: var(--color-surface-muted);
border: none; padding: 2px;
cursor: pointer;
transition: background-color 200ms ease;
}
.toggle--on { background: var(--color-primary); }
.toggle__thumb {
display: block;
width: 20px; height: 20px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
Checkbox — the drawing motion implies human confirmation:
function AnimatedCheckbox({ checked, onChange }) {
return (
<label className="checkbox-wrapper">
<input type="checkbox" checked={checked} onChange={onChange} />
<svg viewBox="0 0 24 24" width={24} height={24}>
{/* Box */}
<motion.rect
x="2" y="2" width="20" height="20" rx="4"
fill="none"
stroke={checked ? "var(--color-primary)" : "var(--color-border)"}
strokeWidth="2"
animate={{ fill: checked ? "var(--color-primary)" : "transparent" }}
transition={{ duration: 0.2 }}
/>
{/* Checkmark strokes in */}
{checked && (
<motion.path
d="M6 12 L10 16 L18 8"
fill="none" stroke="white"
strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
/>
)}
</svg>
</label>
);
}
Notification & Status
Badge appear:
<AnimatePresence>
{count > 0 && (
<motion.span
key="badge"
className="badge"
initial={{ scale: 0 }}
animate={{ scale: [0, 1.2, 1] }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
{count}
</motion.span>
)}
</AnimatePresence>
Copy-to-clipboard feedback:
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button onClick={copy} aria-label={copied ? "Copied!" : "Copy to clipboard"}>
<AnimatePresence mode="wait">
{copied
? <motion.span key="check" initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} exit={{ opacity: 0 }}>✓</motion.span>
: <motion.span key="copy" initial={{ opacity: 0 }}
animate={{ opacity: 1 }} exit={{ opacity: 0 }}>⎘</motion.span>
}
</AnimatePresence>
</button>
);
}
Accessibility Requirements
Every microinteraction must include:
aria-label on interactive elements describing their state (not just their default action)
aria-live="polite" on status messages that appear after async actions
- No color-only communication — combine color change with motion AND icon/text
prefers-reduced-motion fallback for all animations
@media (prefers-reduced-motion: reduce) {
.skeleton-line, .skeleton-shimmer {
animation: none;
opacity: 0.6;
}
}
Additional Resources
references/pattern-catalog.md — Extended pattern library: star ratings, progress rings, swipe actions, pull-to-refresh indicators, number counting animations, and 20+ more patterns with full code