Clean, fine-tuned, microinteractions
A hands-on collection of smooth, multi-step UI interactions.
Profile Dropdown Menu
Click the avatar to open a menu that slides and fades in. Hover between items to see the highlight glide, flip the theme toggle, or open the nested submenu.
'use client';
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronDown, ChevronRight, CreditCard, LogOut, Moon, Settings, User } from 'lucide-react';
const menuItems = [
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'billing', label: 'Billing', icon: CreditCard },
];
export default function ProfileDropdown() {
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
const [darkMode, setDarkMode] = useState(true);
const [moreOpen, setMoreOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Close on outside click or Escape
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setMoreOpen(false);
}
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
return (
<motion.div
ref={ref}
className="relative flex flex-col items-center gap-2"
>
<motion.button
layout="position"
onClick={() => setOpen((v) => !v)}
transition={{ layout: { duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] } }}
className="dropdown-trigger"
>
Account
<motion.span animate={{ rotate: open ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown className="size-4" />
</motion.span>
</motion.button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] }}
className="overflow-hidden"
>
<motion.div className="dropdown-panel">
{menuItems.map((item) => (
<button
key={item.id}
onMouseEnter={() => setHovered(item.id)}
onMouseLeave={() => setHovered(null)}
className="dropdown-item"
>
{/* Shared layoutId makes the highlight glide between items */}
{hovered === item.id && (
<motion.span
layoutId="dropdown-highlight"
className="dropdown-highlight"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
<item.icon className="size-4" />
{item.label}
</button>
))}
{/* Toggle switch row */}
<div className="dropdown-item dropdown-item--row">
<span><Moon className="size-4" /> Dark mode</span>
<button
role="switch"
aria-checked={darkMode}
onClick={() => setDarkMode((v) => !v)}
className={`switch ${darkMode ? 'switch--on' : ''}`}
>
<motion.span layout transition={{ type: 'spring', stiffness: 700, damping: 30 }} className="switch-knob" />
</button>
</div>
{/* Expandable submenu */}
<button onClick={() => setMoreOpen((v) => !v)} className="dropdown-item dropdown-item--row">
<span><Settings className="size-4" /> More options</span>
<motion.span animate={{ rotate: moreOpen ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight className="size-4" />
</motion.span>
</button>
<AnimatePresence initial={false}>
{moreOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.04, 0.62, 0.23, 0.98] }}
className="overflow-hidden"
>
<div className="dropdown-submenu">
<button className="dropdown-item">Keyboard shortcuts</button>
<button className="dropdown-item">Integrations</button>
<button className="dropdown-item">Download data</button>
</div>
</motion.div>
)}
</AnimatePresence>
<button className="dropdown-item dropdown-item--danger">
<LogOut className="size-4" /> Log out
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}Settings Popover
A floating settings panel with a draggable slider, toggle switches, and a color picker that confirms your choice with an animated checkmark.
'use client';
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Bell, Check, Save, SlidersHorizontal, Volume2 } from 'lucide-react';
const colors = [
{ id: 'violet', value: '#8b5cf6' },
{ id: 'blue', value: '#3b82f6' },
{ id: 'emerald', value: '#10b981' },
{ id: 'rose', value: '#f43f5e' },
];
export default function SettingsPopover() {
const [open, setOpen] = useState(false);
const [volume, setVolume] = useState(60);
const [notifications, setNotifications] = useState(true);
const [autoSave, setAutoSave] = useState(false);
const [color, setColor] = useState('violet');
const ref = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
// Convert a pointer position into a 0-100 value along the track
const updateFromPointer = (clientX: number) => {
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const pct = ((clientX - rect.left) / rect.width) * 100;
setVolume(Math.round(Math.min(100, Math.max(0, pct))));
};
return (
<motion.div
ref={ref}
className="relative flex flex-col items-center gap-2"
>
<motion.button
layout="position"
onClick={() => setOpen((v) => !v)}
transition={{ layout: { duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] } }}
className="settings-trigger"
>
<SlidersHorizontal className="size-4 settings-trigger-icon" />
<span className="settings-trigger-label">Customize</span>
</motion.button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] }}
className="overflow-hidden"
>
<div className="settings-panel">
{/* Draggable slider */}
<div className="settings-row">
<span><Volume2 className="size-4" /> Volume</span>
<span>{volume}%</span>
</div>
<div
ref={trackRef}
onClick={(e) => updateFromPointer(e.clientX)}
className="slider-track"
>
<div className="slider-fill" style={{ width: `${volume}%` }} />
<motion.div
onPan={(_e, info) => updateFromPointer(info.point.x)}
whileTap={{ scale: 1.25 }}
className="slider-knob"
style={{ left: `${volume}%` }}
/>
</div>
{/* Toggle switches */}
<ToggleRow icon={Bell} label="Notifications" checked={notifications} onChange={setNotifications} />
<ToggleRow icon={Save} label="Auto-save" checked={autoSave} onChange={setAutoSave} />
{/* Color picker with animated checkmark */}
<div className="swatch-row">
{colors.map((swatch) => (
<button
key={swatch.id}
onClick={() => setColor(swatch.id)}
className="swatch"
style={{ backgroundColor: swatch.value }}
>
{color === swatch.id && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
className="swatch-check"
>
<Check className="size-3.5" />
</motion.span>
)}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
function ToggleRow({ icon: Icon, label, checked, onChange }: any) {
return (
<div className="settings-row">
<span><Icon className="size-4" /> {label}</span>
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`switch ${checked ? 'switch--on' : ''}`}
>
<motion.span layout transition={{ type: 'spring', stiffness: 700, damping: 30 }} className="switch-knob" />
</button>
</div>
);
}Expandable Action Card
Click the card to expand it and reveal a row of action buttons that stagger into view, each with its own hover and tap feedback.
Designing with motion
Notes on building interfaces that feel alive without getting in the way.
'use client';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Bookmark, ChevronDown, Heart, MessageCircle, Share2 } from 'lucide-react';
const listVariants = {
hidden: {},
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
};
const itemVariants = {
hidden: { opacity: 0, y: 8, scale: 0.9 },
visible: { opacity: 1, y: 0, scale: 1 },
};
export default function ExpandableActionCard() {
const [expanded, setExpanded] = useState(false);
const [liked, setLiked] = useState(false);
const [saved, setSaved] = useState(false);
const [burstKey, setBurstKey] = useState(0);
const toggleLike = () => {
setLiked((v) => {
if (!v) setBurstKey((k) => k + 1);
return !v;
});
};
return (
// "layout" animates the card's height smoothly as content is added/removed
<motion.div
layout
onClick={() => setExpanded((v) => !v)}
className="card"
transition={{ layout: { duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] } }}
>
<motion.div layout="position" className="card-header">
<div className="card-thumb" />
<div>
<h3>Designing with motion</h3>
<p>Notes on building interfaces that feel alive.</p>
</div>
<motion.span animate={{ rotate: expanded ? 180 : 0 }} transition={{ duration: 0.2 }}>
<ChevronDown className="size-4" />
</motion.span>
</motion.div>
<AnimatePresence initial={false}>
{expanded && (
// staggerChildren makes each action button pop in one after another
<motion.div variants={listVariants} initial="hidden" animate="visible" exit="hidden" className="card-actions">
<motion.button
variants={itemVariants}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => { e.stopPropagation(); toggleLike(); }}
className={`action ${liked ? 'action--liked' : ''}`}
>
<Heart className="size-4" fill={liked ? 'currentColor' : 'none'} />
Like
{/* Particle burst radiating outward on like */}
<AnimatePresence>
{liked && Array.from({ length: 8 }).map((_, i) => {
const angle = (i / 8) * Math.PI * 2;
return (
<motion.span
key={`${burstKey}-${i}`}
initial={{ opacity: 1, scale: 0, x: 0, y: 0 }}
animate={{ opacity: 0, scale: 1, x: Math.cos(angle) * 28, y: Math.sin(angle) * 28 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="particle"
/>
);
})}
</AnimatePresence>
</motion.button>
<motion.button
variants={itemVariants}
whileHover={{ scale: 1.08 }}
whileTap={{ scale: 0.9 }}
onClick={(e) => { e.stopPropagation(); setSaved((v) => !v); }}
className={`action ${saved ? 'action--saved' : ''}`}
>
<motion.span animate={saved ? { rotate: [0, -15, 15, 0] } : {}} transition={{ duration: 0.35 }}>
<Bookmark className="size-4" fill={saved ? 'currentColor' : 'none'} />
</motion.span>
Save
</motion.button>
<motion.button variants={itemVariants} whileHover={{ scale: 1.08 }} whileTap={{ scale: 0.9 }} className="action">
<Share2 className="size-4" /> Share
</motion.button>
<motion.span variants={itemVariants} className="card-meta">
<MessageCircle className="size-4" /> 12
</motion.span>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}Two-Step Feedback Popover
Open the feedback popover, rate your experience and leave a note, then watch it morph into an animated success state.
'use client';
import { useEffect, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { MessageSquarePlus } from 'lucide-react';
const moods = [
{ id: 'bad', emoji: '😞', label: 'Bad' },
{ id: 'meh', emoji: '😐', label: 'Meh' },
{ id: 'good', emoji: '🙂', label: 'Good' },
{ id: 'great', emoji: '🤩', label: 'Great' },
];
export default function FeedbackPopover() {
const [open, setOpen] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [mood, setMood] = useState<string | null>(null);
const [message, setMessage] = useState('');
const ref = useRef<HTMLDivElement>(null);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!mood) return;
setSubmitted(true);
// Auto-close after the success state has been shown for a moment
setTimeout(() => {
setOpen(false);
setTimeout(() => {
setSubmitted(false);
setMood(null);
setMessage('');
}, 200);
}, 1600);
}
return (
<motion.div
ref={ref}
className="relative flex flex-col-reverse items-center gap-2"
>
<motion.button
layout="position"
onClick={() => setOpen((v) => !v)}
transition={{ layout: { duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] } }}
className="feedback-trigger"
>
<MessageSquarePlus className="size-4 feedback-trigger-icon" />
<span className="feedback-trigger-label">Feedback</span>
</motion.button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] }}
className="overflow-hidden"
>
<div className="feedback-panel">
{/* AnimatePresence cross-fades between the form and the success state */}
<AnimatePresence mode="popLayout" initial={false}>
{!submitted ? (
<motion.form
key="form"
onSubmit={handleSubmit}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.15 }}
className="feedback-form"
>
<p className="feedback-title">How's your experience?</p>
<div className="mood-row">
{moods.map((option) => (
<button
key={option.id}
type="button"
aria-pressed={mood === option.id}
onClick={() => setMood(option.id)}
className="mood-btn"
>
{mood === option.id && (
<motion.span
layoutId="mood-highlight"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
className="mood-highlight"
/>
)}
<motion.span
animate={{ scale: mood === option.id ? 1.2 : 1 }}
transition={{ type: 'spring', stiffness: 500, damping: 15 }}
className="mood-emoji"
>
{option.emoji}
</motion.span>
</button>
))}
</div>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Tell us more (optional)"
rows={2}
className="feedback-textarea"
/>
<button type="submit" disabled={!mood} className="feedback-submit">
Send feedback
</button>
</motion.form>
) : (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className="feedback-success"
>
<div className="feedback-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
{/* pathLength animates from 0 to 1, drawing the checkmark stroke */}
<motion.path
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
/>
</svg>
</div>
<div>
<p className="feedback-title">Thanks for your feedback!</p>
<p className="feedback-subtitle">We'll use this to improve.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}Command Menu
Open a command palette, search to filter the list in real time, navigate with arrow keys, and select with a satisfying checkmark.
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
BarChart, Calendar, Check, CreditCard, FileText, Folder,
LayoutGrid, Mail, Search, Settings, User,
} from 'lucide-react';
const items = [
{ id: 'new-file', label: 'New file', icon: FileText, group: 'Create' },
{ id: 'new-folder', label: 'New folder', icon: Folder, group: 'Create' },
{ id: 'dashboard', label: 'Go to dashboard', icon: LayoutGrid, group: 'Navigate' },
{ id: 'profile', label: 'Open profile', icon: User, group: 'Navigate' },
{ id: 'calendar', label: 'Open calendar', icon: Calendar, group: 'Navigate' },
{ id: 'analytics', label: 'View analytics', icon: BarChart, group: 'Navigate' },
{ id: 'billing', label: 'Manage billing', icon: CreditCard, group: 'Settings' },
{ id: 'settings', label: 'Open settings', icon: Settings, group: 'Settings' },
{ id: 'email', label: 'Compose email', icon: Mail, group: 'Create' },
];
export default function CommandMenu() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const ref = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => item.label.toLowerCase().includes(q));
}, [query]);
function openMenu() {
setOpen(true);
setQuery('');
setSelectedId(null);
setActiveIndex(0);
requestAnimationFrame(() => inputRef.current?.focus());
}
// Global Cmd+K / Ctrl+K shortcut to toggle the menu
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen((v) => {
if (v) return false;
openMenu();
return true;
});
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
// Close on outside click / Escape
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const handleKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
function handleQueryChange(value: string) {
setQuery(value);
setActiveIndex(0);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
const item = filtered[activeIndex];
if (!item) return;
setSelectedId(item.id);
setTimeout(() => setOpen(false), 400);
} else if (e.key === 'Escape') {
setOpen(false);
}
}
return (
<motion.div ref={ref} className="relative flex flex-col items-center">
{/* The shell only morphs its width and border radius — its height never
changes, so this layout animation stays decoupled from the results
panel growing in below it. The two pieces sit flush with matching
radii so they read as one component. */}
<motion.div
layout
transition={{ layout: { duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] } }}
style={{
borderTopLeftRadius: open ? 16 : 9999,
borderTopRightRadius: open ? 16 : 9999,
borderBottomLeftRadius: open ? 0 : 9999,
borderBottomRightRadius: open ? 0 : 9999,
}}
className={`command-shell ${open ? 'command-shell--open' : 'command-shell--closed'}`}
>
{open ? (
<>
<Search className="size-4 shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a command or search..."
/>
<kbd className="shrink-0">esc</kbd>
</>
) : (
<button type="button" onClick={openMenu} className="command-trigger">
<Search className="size-4 shrink-0" />
<span className="command-trigger-label">Search commands...</span>
<kbd className="shrink-0">⌘K</kbd>
</button>
)}
</motion.div>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: [0.04, 0.62, 0.23, 0.98] }}
className="command-results-wrapper"
>
<div className="command-results">
<div className="command-list">
{filtered.length === 0 && <p className="command-empty">No results found.</p>}
{filtered.map((item, index) => {
const Icon = item.icon;
const isActive = index === activeIndex;
const isSelected = selectedId === item.id;
return (
<button
key={item.id}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
setSelectedId(item.id);
setTimeout(() => setOpen(false), 400);
}}
className="command-item"
>
{/* layoutId makes the highlight glide between items as activeIndex changes */}
{isActive && (
<motion.span
layoutId="command-highlight"
transition={{ type: 'spring', stiffness: 500, damping: 40 }}
className="command-highlight"
/>
)}
<Icon className="relative size-4" />
<span className="relative flex-1">{item.label}</span>
<span className="relative command-group">{item.group}</span>
<AnimatePresence>
{isSelected && (
<motion.span
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 500, damping: 25 }}
className="relative command-check"
>
<Check className="size-4" />
</motion.span>
)}
</AnimatePresence>
</button>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}