Microinteractions and Motion Design
Animated feedback, delightful transitions, and purposeful motion that make interfaces feel alive, responsive, and intuitive.
Core Principles
1. Feedback Over Decoration
Every animation should communicate something: confirmation, progress, state change. Motion without purpose is noise.
2. Natural and Physical
Animations should obey a sense of physics. Elements accelerate into motion and decelerate to a stop, not teleport.
3. Fast by Default
Most UI animations should complete in 150-400ms. Users notice delays above 400ms and get frustrated above 1000ms.
4. Consistent Timing Language
Use the same easing curves and durations across your product. A button hover should feel like it belongs with a modal open.
5. Respect User Preferences
Always provide a reduced-motion fallback. Some users experience motion sickness or have vestibular disorders.
Animation Timing Reference
| Duration | Use Case | Examples |
|---|---|---|
| 50-100ms | Micro feedback | Button color change, checkbox tick |
| 150-250ms | Small transitions | Hover states, tooltips, dropdowns |
| 250-400ms | Medium transitions | Modal open/close, page transitions |
| 400-700ms | Large transitions | Full-screen overlays, hero animations |
| 1000ms+ | Decorative/narrative | Loading sequences, onboarding |
Easing Functions
:root {
/* Standard easings */
--ease-out: cubic-bezier(0.0, 0.0, 0.2, 1); /* Decelerate - entering elements */
--ease-in: cubic-bezier(0.4, 0.0, 1, 1); /* Accelerate - exiting elements */
--ease-in-out: cubic-bezier(0.4, 0.0, 0.2, 1); /* Standard - moving elements */
/* Expressive easings */
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot and settle */
--ease-snap: cubic-bezier(0.5, 0, 0.1, 1); /* Quick snap into place */
--ease-smooth: cubic-bezier(0.25, 0.1, 0.25, 1); /* Gentle, smooth motion */
/* Spring-like */
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
/* Standard durations */
--duration-fast: 150ms;
--duration-normal: 250ms;
--duration-slow: 400ms;
--duration-slower: 600ms;
}
Hover States
Button Hover Effects
/* Basic lift effect */
.btn-lift {
padding: 12px 28px;
background: #6c5ce7;
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.btn-lift:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.4);
}
.btn-lift:active {
transform: translateY(0);
box-shadow: 0 1px 4px rgba(108, 92, 231, 0.3);
}
/* Fill sweep effect */
.btn-sweep {
position: relative;
padding: 12px 28px;
background: transparent;
color: #6c5ce7;
border: 2px solid #6c5ce7;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
overflow: hidden;
z-index: 1;
transition: color var(--duration-normal) var(--ease-out);
}
.btn-sweep::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: #6c5ce7;
z-index: -1;
transition: left var(--duration-normal) var(--ease-out);
}
.btn-sweep:hover {
color: #ffffff;
}
.btn-sweep:hover::before {
left: 0;
}
/* Pulse glow effect */
.btn-glow {
padding: 12px 28px;
background: #6c5ce7;
color: #ffffff;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: box-shadow var(--duration-normal) var(--ease-out);
}
.btn-glow:hover {
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.3);
}
.btn-glow:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(108, 92, 231, 0.5);
}
Card Hover Effects
/* Subtle lift with shadow growth */
.card-hover {
padding: 32px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e2e8f0;
transition: transform var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
border-color var(--duration-normal) var(--ease-out);
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
border-color: transparent;
}
/* Image zoom within card */
.card-image-zoom {
overflow: hidden;
border-radius: 12px 12px 0 0;
}
.card-image-zoom img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--duration-slow) var(--ease-out);
}
.card-hover:hover .card-image-zoom img {
transform: scale(1.05);
}
Link Hover Effects
/* Underline grow from center */
.link-grow {
color: #2d3436;
text-decoration: none;
position: relative;
}
.link-grow::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
width: 0;
height: 2px;
background: #6c5ce7;
transition: width var(--duration-normal) var(--ease-out),
left var(--duration-normal) var(--ease-out);
}
.link-grow:hover::after {
width: 100%;
left: 0;
}
/* Underline slide from left */
.link-slide {
color: #2d3436;
text-decoration: none;
position: relative;
}
.link-slide::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: #6c5ce7;
transition: width var(--duration-normal) var(--ease-out);
}
.link-slide:hover::after {
width: 100%;
}
/* Background highlight */
.link-highlight {
color: #2d3436;
text-decoration: none;
padding: 2px 4px;
margin: -2px -4px;
border-radius: 4px;
transition: background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.link-highlight:hover {
background: #6c5ce7;
color: #ffffff;
}
Loading Animations
Skeleton Screens
<div class="skeleton-card">
<div class="skeleton skeleton--image"></div>
<div class="skeleton skeleton--title"></div>
<div class="skeleton skeleton--text"></div>
<div class="skeleton skeleton--text skeleton--short"></div>
</div>
.skeleton {
background: #e2e8f0;
border-radius: 4px;
position: relative;
overflow: hidden;
}
/* Shimmer animation */
.skeleton::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
100% {
left: 100%;
}
}
.skeleton--image {
width: 100%;
height: 200px;
border-radius: 8px;
margin-bottom: 16px;
}
.skeleton--title {
width: 60%;
height: 24px;
margin-bottom: 12px;
}
.skeleton--text {
width: 100%;
height: 16px;
margin-bottom: 8px;
}
.skeleton--short {
width: 40%;
}
Spinner Variations
/* Simple ring spinner */
.spinner-ring {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top-color: #6c5ce7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dot pulse */
.spinner-dots {
display: flex;
gap: 8px;
}
.spinner-dots__dot {
width: 10px;
height: 10px;
background: #6c5ce7;
border-radius: 50%;
animation: pulse 1.4s ease-in-out infinite;
}
.spinner-dots__dot:nth-child(2) { animation-delay: 0.2s; }
.spinner-dots__dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Progress bar */
.progress-bar {
width: 100%;
height: 4px;
background: #e2e8f0;
border-radius: 2px;
overflow: hidden;
}
.progress-bar__fill {
height: 100%;
background: #6c5ce7;
border-radius: 2px;
transition: width var(--duration-slow) var(--ease-out);
}
/* Indeterminate progress */
.progress-bar--indeterminate .progress-bar__fill {
width: 30%;
animation: indeterminate 1.5s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
Transitions
Modal Open/Close
<div class="modal-overlay" id="modal">
<div class="modal-content">
<h2>Modal Title</h2>
<p>Modal content goes here.</p>
<button onclick="closeModal()">Close</button>
</div>
</div>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: grid;
place-items: center;
opacity: 0;
visibility: hidden;
transition: opacity var(--duration-normal) var(--ease-out),
visibility var(--duration-normal);
z-index: 1000;
}
.modal-overlay.is-open {
opacity: 1;
visibility: visible;
}
.modal-content {
background: #ffffff;
border-radius: 16px;
padding: 40px;
max-width: 500px;
width: 90%;
transform: translateY(20px) scale(0.95);
transition: transform var(--duration-normal) var(--ease-out);
}
.modal-overlay.is-open .modal-content {
transform: translateY(0) scale(1);
}
Accordion / Collapsible
<div class="accordion">
<button class="accordion__trigger" aria-expanded="false">
<span>Section Title</span>
<svg class="accordion__icon" viewBox="0 0 24 24" width="20" height="20">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</button>
<div class="accordion__panel">
<div class="accordion__content">
<p>Collapsible content goes here.</p>
</div>
</div>
</div>
.accordion__trigger {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 16px 20px;
background: none;
border: none;
border-bottom: 1px solid #e2e8f0;
font-size: 1rem;
font-weight: 600;
color: #2d3436;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.accordion__trigger:hover {
background: #f8f9fa;
}
.accordion__icon {
transition: transform var(--duration-normal) var(--ease-out);
}
.accordion__trigger[aria-expanded="true"] .accordion__icon {
transform: rotate(180deg);
}
.accordion__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows var(--duration-normal) var(--ease-out);
}
.accordion__trigger[aria-expanded="true"] + .accordion__panel {
grid-template-rows: 1fr;
}
.accordion__content {
overflow: hidden;
}
Tab Switching
/* Animated tab indicator */
.tabs {
position: relative;
display: flex;
gap: 0;
border-bottom: 2px solid #e2e8f0;
}
.tab {
padding: 12px 24px;
background: none;
border: none;
font-size: 1rem;
font-weight: 500;
color: #636e72;
cursor: pointer;
transition: color var(--duration-fast) var(--ease-out);
}
.tab.is-active {
color: #6c5ce7;
}
/* Sliding indicator bar */
.tabs__indicator {
position: absolute;
bottom: -2px;
height: 2px;
background: #6c5ce7;
transition: left var(--duration-normal) var(--ease-out),
width var(--duration-normal) var(--ease-out);
}
/* Tab content crossfade */
.tab-panel {
opacity: 0;
transform: translateY(8px);
transition: opacity var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
display: none;
}
.tab-panel.is-active {
display: block;
opacity: 1;
transform: translateY(0);
}
Scroll Animations
Reveal on Scroll (CSS Only)
/* Use CSS animation-timeline for scroll-driven animations */
.scroll-reveal {
opacity: 0;
transform: translateY(30px);
animation: reveal linear forwards;
animation-timeline: view();
animation-range: entry 0% entry 30%;
}
@keyframes reveal {
to {
opacity: 1;
transform: translateY(0);
}
}
Scroll Reveal With Intersection Observer
<div class="reveal" data-reveal>
<h2>This appears on scroll</h2>
</div>
.reveal {
opacity: 0;
transform: translateY(40px);
transition: opacity var(--duration-slow) var(--ease-out),
transform var(--duration-slow) var(--ease-out);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Stagger children */
.reveal.is-visible > *:nth-child(1) { transition-delay: 0ms; }
.reveal.is-visible > *:nth-child(2) { transition-delay: 100ms; }
.reveal.is-visible > *:nth-child(3) { transition-delay: 200ms; }
.reveal.is-visible > *:nth-child(4) { transition-delay: 300ms; }
/* Intersection Observer for scroll reveals */
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); /* animate once */
}
});
},
{ threshold: 0.15, rootMargin: '0px 0px -50px 0px' }
);
document.querySelectorAll('[data-reveal]').forEach(el => {
observer.observe(el);
});
Parallax Scrolling
/* Pure CSS parallax with perspective */
.parallax-container {
height: 100vh;
overflow-x: hidden;
overflow-y: auto;
perspective: 1px;
}
.parallax-layer--back {
transform: translateZ(-1px) scale(2);
position: absolute;
inset: 0;
z-index: -1;
}
.parallax-layer--front {
transform: translateZ(0);
position: relative;
z-index: 1;
}
Scroll-Linked Progress Bar
/* Page scroll progress indicator */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: transparent;
z-index: 9999;
}
.scroll-progress__bar {
height: 100%;
background: #6c5ce7;
width: 0%;
animation: scroll-progress linear forwards;
animation-timeline: scroll(root);
}
@keyframes scroll-progress {
to { width: 100%; }
}
CSS Animation Techniques
Staggered Grid Animation
.stagger-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.stagger-grid__item {
opacity: 0;
transform: translateY(20px);
animation: stagger-in var(--duration-slow) var(--ease-out) forwards;
}
/* Calculate delay based on position */
.stagger-grid__item:nth-child(1) { animation-delay: 0ms; }
.stagger-grid__item:nth-child(2) { animation-delay: 80ms; }
.stagger-grid__item:nth-child(3) { animation-delay: 160ms; }
.stagger-grid__item:nth-child(4) { animation-delay: 240ms; }
.stagger-grid__item:nth-child(5) { animation-delay: 320ms; }
.stagger-grid__item:nth-child(6) { animation-delay: 400ms; }
@keyframes stagger-in {
to {
opacity: 1;
transform: translateY(0);
}
}
Notification Toast
<div class="toast is-visible">
<span class="toast__icon">✓</span>
<span class="toast__message">Changes saved successfully</span>
</div>
.toast {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
background: #2d3436;
color: #ffffff;
border-radius: 8px;
font-size: 0.9375rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
transform: translateY(100px);
opacity: 0;
transition: transform var(--duration-normal) var(--ease-spring),
opacity var(--duration-normal) var(--ease-out);
z-index: 9999;
}
.toast.is-visible {
transform: translateY(0);
opacity: 1;
}
.toast__icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #00b894;
border-radius: 50%;
font-size: 0.75rem;
flex-shrink: 0;
}
Toggle Switch
<label class="toggle">
<input type="checkbox" class="toggle__input" />
<span class="toggle__track">
<span class="toggle__thumb"></span>
</span>
</label>
.toggle__input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle__track {
display: inline-flex;
align-items: center;
width: 48px;
height: 28px;
background: #cbd5e0;
border-radius: 14px;
padding: 2px;
cursor: pointer;
transition: background var(--duration-normal) var(--ease-out);
}
.toggle__thumb {
width: 24px;
height: 24px;
background: #ffffff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform var(--duration-normal) var(--ease-spring);
}
.toggle__input:checked + .toggle__track {
background: #6c5ce7;
}
.toggle__input:checked + .toggle__track .toggle__thumb {
transform: translateX(20px);
}
.toggle__input:focus-visible + .toggle__track {
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.4);
}
Ripple Effect
/* Material-style ripple on click */
.ripple {
position: relative;
overflow: hidden;
}
.ripple::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
transform: translate(-50%, -50%);
transition: width 0.5s ease, height 0.5s ease, opacity 0.5s ease;
opacity: 0;
}
.ripple:active::after {
width: 300px;
height: 300px;
opacity: 1;
transition: 0s;
}
Page Transitions
Crossfade Between Pages
/* View Transitions API (modern browsers) */
@view-transition {
navigation: auto;
}
/* Default crossfade */
::view-transition-old(root) {
animation: fade-out 250ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 250ms ease-in;
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Slide transition for specific elements */
.hero-image {
view-transition-name: hero;
}
::view-transition-old(hero) {
animation: slide-out-left 300ms ease-out;
}
::view-transition-new(hero) {
animation: slide-in-right 300ms ease-out;
}
@keyframes slide-out-left {
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
}
Performance Considerations
What to Animate
| Property | Performance | GPU Accelerated | Recommendation |
|---|---|---|---|
transform | Excellent | Yes | Preferred for all movement |
opacity | Excellent | Yes | Preferred for show/hide |
filter | Good | Yes | Use sparingly |
background-color | OK | No | Acceptable for hover states |
color | OK | No | Acceptable for text changes |
box-shadow | Poor | No | Avoid animating; use pseudo-element |
width/height | Poor | No | Use transform: scale() instead |
top/left | Poor | No | Use transform: translate() instead |
border-radius | Poor | No | Avoid animating |
margin/padding | Terrible | No | Never animate these |
Performance Best Practices
/* GOOD: animate only transform and opacity (composited) */
.performant {
transition: transform 250ms ease-out, opacity 250ms ease-out;
}
.performant:hover {
transform: translateY(-4px) scale(1.02);
opacity: 0.9;
}
/* BAD: animating layout properties causes reflow */
.slow {
transition: width 250ms, height 250ms, margin 250ms;
}
/* Promote to GPU layer for complex animations */
.gpu-layer {
will-change: transform;
/* Remove will-change after animation completes */
}
/* Use contain for isolated animations */
.animated-card {
contain: layout style paint;
}
Animating Box Shadows Efficiently
/* BAD: directly animating box-shadow is expensive */
.card-bad:hover {
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
/* GOOD: animate a pseudo-element's opacity instead */
.card-good {
position: relative;
}
.card-good::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
opacity: 0;
transition: opacity var(--duration-normal) var(--ease-out);
z-index: -1;
}
.card-good:hover::after {
opacity: 1;
}
Reduced Motion
/* CRITICAL: always respect user preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Or selectively remove non-essential animations */
@media (prefers-reduced-motion: reduce) {
.scroll-reveal {
opacity: 1;
transform: none;
animation: none;
}
.parallax-layer--back {
transform: none;
}
/* Keep functional animations (loading spinners, progress) */
.spinner-ring {
animation-duration: 1.5s; /* slow down but keep */
}
}
When to Use Microinteractions
Perfect For
- SaaS products: Feedback on saves, deletes, status changes
- E-commerce: Add-to-cart, wishlist, checkout progress
- Forms: Validation feedback, step transitions
- Dashboards: Data loading, chart reveals, filter transitions
- Mobile apps: Touch feedback, pull-to-refresh, swipe actions
- Portfolios: Project reveals, image galleries, page transitions
Real-World Examples
- Stripe - Smooth page transitions, animated gradients
- Linear - Silky keyboard shortcuts, list animations
- Vercel - Deployment progress, subtle hover states
- Notion - Block drag-and-drop, inline transitions
- Apple - Scroll-driven product reveals
- GitHub - Contribution graph, PR merge animations
Avoid For
- Content-heavy sites: Don't slow down reading with animation
- Accessibility-critical: Government, healthcare (keep functional only)
- Slow connections: Heavy animation punishes low-bandwidth users
- Data entry forms: Speed matters more than delight
Pros and Cons
Advantages
- Makes interfaces feel responsive and alive
- Provides clear feedback on user actions
- Guides attention to important changes
- Creates emotional connection and delight
- Can explain spatial relationships (where things come from/go to)
- Differentiates from static competitors
Disadvantages
- Easy to overdo; too much motion is distracting and nauseating
- Performance impact if using wrong properties
- Increases development and testing time
- Can annoy power users who want speed
- Must maintain reduced-motion alternatives
- Inconsistent animation is worse than no animation
Common Mistakes
1. Animating Everything
Wrong: Every element bounces, slides, and fades on every page load.
Right: Animate meaningfully. State changes, user actions, and attention guidance.
2. Using Wrong Properties
Wrong: Animating width, height, top, left, margin.
/* Wrong - triggers layout recalculation every frame */
.card:hover {
width: 320px;
margin-top: -10px;
}
Right: Use transform and opacity exclusively for motion.
/* Right - GPU-composited, smooth 60fps */
.card:hover {
transform: scale(1.05) translateY(-10px);
}
3. Ignoring Reduced Motion
Wrong: No prefers-reduced-motion media query.
Right: Always provide a reduced-motion fallback (see Performance section).
4. Inconsistent Timing
Wrong: Button hovers at 100ms, cards at 500ms, modals at 200ms, no cohesion.
Right: Define a timing system with CSS custom properties and use it everywhere.
5. Blocking User Input
Wrong: Long entrance animations that prevent interaction.
/* Wrong - user can't click for 1.5 seconds */
.hero { animation: elaborate-entrance 1.5s ease; }
Right: Keep entrance animations under 500ms, or make elements interactive immediately.
Advanced Techniques
Shared Element Transitions
/* Assign view-transition-name to elements that persist across pages */
.product-card__image {
view-transition-name: product-image;
}
.product-detail__image {
view-transition-name: product-image;
}
/* The browser automatically morphs between the two positions */
::view-transition-group(product-image) {
animation-duration: 350ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
Number Counter Animation
/* Animate a number counting up with CSS @property */
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.counter {
--num: 0;
animation: count-up 2s ease-out forwards;
counter-reset: num var(--num);
font-size: 3rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.counter::after {
content: counter(num);
}
@keyframes count-up {
to { --num: 1250; }
}
Magnetic Hover Effect
/* Element follows cursor position within its bounds */
document.querySelectorAll('.magnetic').forEach(el => {
el.addEventListener('mousemove', (e) => {
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
el.style.transform = `translate(${x * 0.3}px, ${y * 0.3}px)`;
});
el.addEventListener('mouseleave', () => {
el.style.transform = 'translate(0, 0)';
el.style.transition = 'transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)';
});
el.addEventListener('mouseenter', () => {
el.style.transition = 'none';
});
});
Checklist for Microinteractions
- [ ] All interactive elements have hover/focus/active states
- [ ] Animations use
transformandopacity(GPU-composited) - [ ] Timing system defined with CSS custom properties
- [ ] Easing curves feel natural (no linear transitions on UI elements)
- [ ] Duration is appropriate: 150-400ms for most UI transitions
- [ ]
prefers-reduced-motionis fully supported - [ ] Loading states use skeleton screens or spinners
- [ ] Focus states are visible for keyboard navigation
- [ ] No animation blocks user interaction
- [ ] Animations are consistent across the product
- [ ]
will-changeused sparingly and removed after animation - [ ] Page transitions are smooth (View Transitions API or JS)
- [ ] Performance tested on low-end devices
Resources
Inspiration
- UI Movement - Curated UI animation gallery
- Hover.css - Collection of CSS hover effects
- Easings.net - Visual easing function reference
- Cubic Bezier - Interactive easing curve tool
Libraries
- Framer Motion - React animation library
- GSAP - Professional JavaScript animation
- Lottie - After Effects animations for web
- Motion One - Lightweight animation library
- Animate.css - Pre-built CSS animations
Further Reading
- Animation at Work by Rachel Nabors - Principles of UI animation
- Designing Interface Animation by Val Head - Practical motion design
- An Interactive Guide to CSS Transitions - Josh Comeau
- Web Animation Performance Guide - web.dev