Emotional design and polish for delightful user experiences. Load this skill when polishing empty states, loading states, error states, adding celebrations and personality, implementing dark mode as a system, adding illustrations, or doing a final polish pass before shipping. Covers empty state quality ladders, loading state psychology, micro-interactions, illustration strategy, visual hierarchy, emotional design patterns, dark mode architecture, and mobile delight. Use alongside interaction-motion (animation) and visual-design (foundations).
Creating apps users love, not just use. This skill covers the emotional and aesthetic elements that transform functional software into delightful experiences.
shadcn/ui + Tailwind gives you a professional foundation. But "professional" ≠ "loved."
| Level | What It Looks Like | User Reaction |
|---|---|---|
| Functional | Works correctly | "It works" |
| Usable | Easy to navigate | "It's fine" |
| Beautiful | Visually polished | "It's nice" |
| Delightful | Emotionally resonant | "I love this" |
This skill focuses on the jump from Beautiful → Delightful. Research shows the aesthetic-usability effect is real — users perceive beautiful interfaces as more functional. But delight goes beyond aesthetics into emotional connection.
Empty states are your biggest opportunity to build connection. Users see them first. An empty screen creates decision paralysis and increases time-to-value.
Not all empty states are equal. Aim for level 3-4:
Best practice: combine levels 2-4. Pre-load demo data instead of showing empty states wherever possible.
// ❌ Lazy empty state (Level 0)
{items.length === 0 && <p>No items found.</p>}
// ❌ Depressing empty state
<div className="text-gray-500">Nothing here yet.</div>
// ✅ Inviting empty state (Level 4)
<div className="flex flex-col items-center py-12 text-center">
<IllustrationEmptyInbox className="w-48 h-48 mb-6" />
<h3 className="text-xl font-semibold mb-2">Your inbox is clear!</h3>
<p className="text-gray-600 mb-6 max-w-sm">
When you receive messages, they'll show up here. Ready to get started?
</p>
<Button>
<Plus className="w-4 h-4 mr-2" />
Send your first message
</Button>
</div>
| Context | Tone | Example Headline |
|---|---|---|
| First time user | Welcoming, exciting | "Let's build something great" |
| No results | Helpful, guiding | "No matches — try adjusting your filters" |
| Completed state | Celebratory | "All done! You're a productivity machine" |
| Error/failure | Empathetic, actionable | "Something went wrong — here's what to try" |
| New feature area | Educational | "Track your team's progress here" |
| Post-completion | Encouraging rest | GitHub's Octocat strolling through a forest |
When users encounter an unused feature, the empty state should explain what it does and why they'd want it. Linear does this well — the empty state for a new feature doubles as its best marketing.
Loading states are moments to reduce perceived wait time and maintain engagement. Research shows skeleton screens feel approximately 20% faster than spinners for identical wait times.
// ❌ Generic spinner
{loading && <Spinner />}
// ❌ Blank screen
{loading && null}
// ✅ Skeleton that matches content shape
<div className="space-y-4">
<Skeleton className="h-8 w-3/4" /> {/* Title */}
<Skeleton className="h-4 w-full" /> {/* Line 1 */}
<Skeleton className="h-4 w-5/6" /> {/* Line 2 */}
</div>
// ✅ Progressive loading with context
<div className="flex flex-col items-center py-8">
<LoadingAnimation type="analyzing" />
<p className="text-sm text-gray-600 mt-4">
Analyzing your data...
</p>
<p className="text-xs text-gray-400 mt-1">
This usually takes about 5 seconds
</p>
</div>
// ✅ Optimistic UI
const [items, setItems] = useState(data)
async function addItem(item) {
// Show immediately
setItems([...items, { ...item, isPending: true }])
try {
const saved = await api.create(item)
setItems(prev => prev.map(i => i.id === item.id ? saved : i))
} catch {
setItems(prev => prev.filter(i => i.id !== item.id))
toast.error("Failed to add item")
}
}
Small animations that provide feedback and create polish. See interaction-motion skill for detailed motion principles.
// Button press feedback (squash & stretch)
<button className="active:scale-[0.97] transition-transform duration-100">
// Hover lift
<div className="hover:-translate-y-0.5 hover:shadow-lg transition-all duration-200">
// Success checkmark animation
<motion.svg initial={{ pathLength: 0 }} animate={{ pathLength: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}>
<motion.path d="M5 13l4 4L19 7" />
</motion.svg>
| Action | Animation | Duration |
|---|---|---|
| Button click | Scale down | 50-100ms |
| Modal open | Fade + slide | 200ms |
| Page transition | Fade + y | 300ms |
| Success confirmation | Checkmark draw | 500ms |
| Celebrating | Confetti, bounce | 1000ms+ |
// Respect reduced motion
const prefersReducedMotion = useReducedMotion()
<motion.div animate={prefersReducedMotion ? {} : { scale: [1, 1.1, 1] }}>
| Scenario | Best Choice |
|---|---|
| Empty states | Custom illustration |
| Error pages | Friendly illustration |
| Feature explanations | Spot illustrations |
| Hero sections | Photo or 3D render |
| Backgrounds | Abstract patterns |
| Onboarding steps | Instructional illustration |
| Source | Best For | Cost |
|---|---|---|
| unDraw | Generic illustrations | Free |
| Storyset | Animated illustrations | Free |
| Humaaans | People illustrations | Free |
| Blush | Customizable illustrations | Freemium |
| Icons8 Illustrations | Variety of styles | Freemium |
| Custom (AI-generated) | Brand-specific | Time |
Important note on generic illustrations: Default Humaaans-style illustrations are now overplayed and signal "generic SaaS." If using illustration libraries, customize the colors to match your brand palette. Better yet, invest in a distinctive illustration style that becomes part of your visual identity.
Use AI to generate custom illustrations, backgrounds, and imagery.
Prompt Template for App Illustrations:
[Style]: Flat illustration, soft colors, minimal detail
[Subject]: [What you need]
[Mood]: [Emotion - productive, calm, excited]
[Colors]: Match brand palette - primary: [hex], accent: [hex]
[Composition]: Centered, white/transparent background
[Format]: PNG, 1024x1024, suitable for web
Tools: Midjourney (best quality), DALL-E 3 (specific compositions), Stable Diffusion (free, customizable), Ideogram (text in images).
// Subtle grid pattern (CSS)
<div className="bg-[linear-gradient(to_right,#8882_1px,transparent_1px),linear-gradient(to_bottom,#8882_1px,transparent_1px)] bg-[size:14px_24px]">
// Gradient mesh
<div className="bg-gradient-to-br from-primary/10 via-transparent to-secondary/10">
// Noise texture overlay
<div className="relative">
<div className="absolute inset-0 bg-noise opacity-5" />
{children}
</div>
Guide the eye to what matters.
Squint at your screen. Can you tell:
If not, hierarchy needs work.
// Size difference
<h1 className="text-4xl font-bold">Primary</h1>
<h2 className="text-xl">Secondary</h2>
<p className="text-base text-gray-600">Supporting</p>
// Color contrast for action hierarchy
<Button className="bg-primary text-white">Primary CTA</Button>
<Button variant="outline">Secondary</Button>
<Button variant="ghost" className="text-gray-500">Tertiary</Button>
// Whitespace isolation (isolation effect: 40% more memorable)
<div className="py-16">
<ImportantContent />
</div>
// Visual weight (borders or surface shifts, not always shadows)
<Card className="border-2 border-primary bg-primary/5">
<FeaturedItem />
</Card>
<Card className="border border-gray-200">
<RegularItem />
</Card>
When users achieve something, celebrate with them. But calibrate to the achievement.
// Confetti on milestone achievement
import confetti from "canvas-confetti";
function onGoalComplete() {
confetti({ particleCount: 100, spread: 70, origin: { y: 0.6 } });
}
// Subtle success for routine operations
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}
transition={{ type: "spring", bounce: 0.5 }}>
<Badge variant="success">✓ Saved</Badge>
</motion.div>
Calibration rule: confetti for milestones (completed onboarding, reached a goal, first sale). A checkmark for routine saves. Over-celebrating routine operations ("Congratulations! Your settings have been saved!") is patronizing.
Add brand voice to UI copy. This is where the product stops feeling like a template.
// ❌ Generic
<Label>Password</Label>
<p className="text-sm text-gray-500">Must be 8+ characters</p>
// ✅ With personality
<Label>Password</Label>
<p className="text-sm text-gray-500">
Make it strong — at least 8 characters, mix it up
</p>
// ❌ Generic error
<p className="text-red-500">Invalid input</p>
// ✅ Helpful and human
<p className="text-red-500">
Hmm, that doesn't look quite right. Need a valid email like [email protected]
</p>
const playSuccess = () => {
const audio = new Audio("/sounds/success.mp3");
audio.volume = 0.3;
audio.play();
};
// Only play if user hasn't disabled
if (!prefersReducedMotion && userSettings.soundEnabled) {
playSuccess();
}
Dark mode is not inverted light mode. It requires its own design system with different rules for elevation, contrast, saturation, and typography weight.
In light mode, elevated surfaces cast shadows. In dark mode, elevated surfaces get lighter. Define 3-5 elevation levels:
const darkElevation = {
base: '#0f0f0f', // Deepest background
surface1: '#1a1a1a', // Cards, sidebars
surface2: '#222222', // Elevated cards, popovers
surface3: '#2a2a2a', // Modals, dropdowns
surface4: '#333333', // Tooltips, highest elevation
}
Never use pure black (#000) for surfaces — it creates excessive contrast and makes shadows invisible. Use dark gray or custom tinted near-blacks.
Never use pure white for body text. Bright text on dark backgrounds appears heavier than the same weight on light backgrounds.
const darkText = {
primary: 'rgba(255, 255, 255, 0.87)', // High emphasis
secondary: 'rgba(255, 255, 255, 0.60)', // Medium emphasis
disabled: 'rgba(255, 255, 255, 0.38)', // Disabled
// Consider reducing body font weight by one step in dark mode
// and slightly increasing letter spacing
}
Key insight: darker surfaces are highly tolerant of saturated colors while still reading as neutral. This is why Linear and Raycast can use vibrant gradients against dark surfaces effectively. Dark mode naturally supports richer, more opinionated color choices without looking garish.
However: saturated colors that work on light backgrounds can look neon on dark ones. Desaturate standard semantic colors (success, error, warning) by 10-20% for dark mode.
// Minimum 44x44px touch targets
<button className="min-h-[44px] min-w-[44px] p-3">
// Generous tap areas
<Link className="block py-4 -mx-4 px-4">
<div className="flex items-center gap-3">
<Icon />
<span>Menu Item</span>
</div>
</Link>
// Bottom sheet instead of modal on mobile
<Sheet>
<SheetTrigger>Open</SheetTrigger>
<SheetContent side="bottom">
<MobileMenu />
</SheetContent>
</Sheet>
function triggerHaptic(type: "light" | "medium" | "heavy") {
if ("vibrate" in navigator) {
const patterns = { light: [10], medium: [20], heavy: [30, 50, 30] };
navigator.vibrate(patterns[type]);
}
}
Before shipping any UI: