Micro-interactions are small, contained animations that respond to a single user action or system event. They are the difference between an interface that feels functional and one that feels polished. Every great app is full of them.
Anatomy of a Micro-Interaction
Every micro-interaction has four parts:
- Trigger — what starts it (user action or system event)
- Rules — what happens during the interaction
- Feedback — the visual or auditory response
- Loops & Modes — does it repeat or change over time?
A "like" button is a perfect example: the trigger is a tap, the rule changes the state from unliked to liked, the feedback is a heart animation, and the mode toggles between liked/unliked states.
Button Feedback
Buttons should feel physical. A button with no feedback leaves users wondering if their click registered:
/* Layered button feedback */
.button {
position: relative;
padding: 0.75rem 1.5rem;
background: #2563eb;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
overflow: hidden;
}
.button:hover {
background: #1d4ed8;
}
.button:active {
transform: scale(0.97);
}
/* Ripple effect */
.button::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle, rgba(255,255,255,0.3) 10%, transparent 70%);
opacity: 0;
transform: scale(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.button:active::after {
opacity: 1;
transform: scale(2.5);
transition: none;
}For submit buttons that trigger async operations, show the loading state inline:
function SubmitButton({ isLoading, children }) {
return (
<motion.button
disabled={isLoading}
whileTap={isLoading ? {} : { scale: 0.97 }}
className="button"
>
{isLoading ? (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center gap-2"
>
<span className="spinner" /> Saving...
</motion.span>
) : (
children
)}
</motion.button>
);
}Toggle Switches
A toggle switch should feel satisfying to flip:
.toggle {
width: 52px;
height: 28px;
border-radius: 14px;
background: #d1d5db;
padding: 2px;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle.active {
background: #2563eb;
}
.toggle-knob {
width: 24px;
height: 24px;
border-radius: 50%;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.toggle.active .toggle-knob {
transform: translateX(24px);
}The cubic-bezier(0.4, 0, 0.2, 1) curve gives the knob a snappy, confident movement that feels like a real switch.
Loading States
Loading indicators should set expectations without causing anxiety:
Skeleton Screens
Replace loading spinners with skeleton screens that show the shape of upcoming content:
.skeleton-text {
height: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 0.25rem;
}
.skeleton-text:nth-child(2) {
width: 80%;
}
.skeleton-text:nth-child(3) {
width: 60%;
}Progress Indicators
For operations with known progress, show a progress bar:
.progress-bar {
height: 4px;
background: #e5e7eb;
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #2563eb;
border-radius: 2px;
transition: width 0.3s ease;
}Notification Animations
Notifications should enter distinctly, sit quietly, and leave gracefully:
function Toast({ message, type, onDismiss }) {
return (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className={`toast toast-${type}`}
>
<span>{message}</span>
<button onClick={onDismiss} aria-label="Dismiss">
×
</button>
</motion.div>
);
}Stack multiple toasts with staggered positioning:
function ToastContainer({ toasts }) {
return (
<div className="fixed top-4 right-4 flex flex-col gap-2 z-50">
<AnimatePresence>
{toasts.map((toast) => (
<Toast key={toast.id} {...toast} />
))}
</AnimatePresence>
</div>
);
}Scroll-Triggered Animations
Elements that animate as they enter the viewport create a dynamic scrolling experience. Use the Intersection Observer API:
import { motion, useInView } from 'framer-motion';
import { useRef } from 'react';
function ScrollReveal({ children }) {
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
{children}
</motion.div>
);
}The once: true option means the animation plays only the first time the element enters the viewport. The margin: '-100px' triggers the animation slightly before the element is fully visible, so it feels seamless.
Guidelines for scroll animations:
- Animate once — repeating animations on every scroll direction is distracting
- Keep them subtle — a 20-30px translateY and opacity change is enough
- Stagger sibling elements — if multiple elements enter at once, stagger by 50-100ms
- Skip them above the fold — content visible on page load should not animate in
Designing Micro-Interactions
Before implementing, ask these questions:
- Does this animation serve a purpose? If it does not provide feedback, orientation, or delight — cut it.
- Is it fast enough? Most micro-interactions should be 100-300ms. If users notice the animation, it might be too slow.
- Does it respect reduced motion? Every micro-interaction needs a reduced-motion fallback.
- Is it consistent? Similar interactions should have similar animations throughout your app.
- Does it scale? An animation that looks great with 3 items might be overwhelming with 30.
The best micro-interactions are invisible. Users do not notice them consciously — the interface just feels right.