---
name: ux-micro-patterns
description: "UX micro-patterns for every product state: Empty States, Loading States (skeleton screens, spinners, optimistic UI), Error States, Success States, Confirmation Dialogs, Onboarding Flows, and Progressive Disclosure. These patterns apply to every feature — done wrong, they're the biggest source of user confusion."
---
# UX Micro-Patterns Skill
## When to Activate
- Implementing any feature that loads, mutates, or can fail
- Designing empty list states, zero-data dashboards
- Building forms with validation and submission states
- Adding confirmation to destructive actions
- Onboarding new users to a feature
- Deciding between spinner vs. skeleton screen
---
## Pattern 1: Empty States
An empty state is not an error — it's an opportunity to guide users.
```
Bad empty state: "No results found."
Good empty state: [Illustration] + Headline + Why it's empty + Primary action
```
```tsx
// components/EmptyState.tsx
interface EmptyStateProps {
icon?: React.ReactNode;
title: string;
description?: string;
action?: { label: string; onClick: () => void };
secondaryAction?: { label: string; onClick: () => void };
}
export function EmptyState({ icon, title, description, action, secondaryAction }: EmptyStateProps) {
return (
{icon && (
{icon}
)}
{title}
{description && (
{description}
)}
{(action || secondaryAction) && (
{action && (
)}
{secondaryAction && (
)}
)}
);
}
// Usage: three distinct contexts
}
title="No notifications yet"
description="When someone mentions you or comments on your work, it'll show up here."
/>
}
title="No results for “{query}”"
description="Try different keywords or remove filters."
action={{ label: 'Clear filters', onClick: clearFilters }}
/>
}
title="Create your first project"
description="Projects help you organize your work and collaborate with your team."
action={{ label: 'New project', onClick: openCreateModal }}
secondaryAction={{ label: 'Import existing', onClick: openImport }}
/>
```
**Empty state content rules:**
- Title: describes the state, not the action ("No projects yet", not "Projects")
- Description: explains why + what to do
- Action: one clear primary CTA — never two equal CTAs
- Illustration: optional but effective; avoid generic stock art
---
## Pattern 2: Loading States
### Skeleton Screens (preferred over spinners for content)
```tsx
// Skeleton: mirrors the shape of the content that's loading
// Rule: use skeleton when load time > 300ms or content has known structure
function ProjectCardSkeleton() {
return (
{/* Header: avatar + name */}
{/* Body: two lines of text */}
{/* Footer */}
);
}
// Usage with TanStack Query
function ProjectList() {
const { data: projects, isLoading } = useProjects();
if (isLoading) {
return (
{Array.from({ length: 6 }).map((_, i) => (
))}
);
}
return (
{projects?.map(p => )}
);
}
```
### Spinner: when to use it
```tsx
// Use spinner for:
// - Actions (button submit, page transitions)
// - Short loads (<300ms expected)
// - When content shape is unknown
function Spinner({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClass = { sm: 'w-4 h-4', md: 'w-6 h-6', lg: 'w-8 h-8' }[size];
return (
);
}
```
### Optimistic UI (fastest perceived performance)
```tsx
// Update UI before server confirms — roll back on error
function TodoItem({ todo }: { todo: Todo }) {
const { mutate: toggleTodo } = useMutation({
mutationFn: (id: string) => api.patch(`/todos/${id}/toggle`),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
// Optimistically flip the checkbox
queryClient.setQueryData(['todos'], old =>
old?.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_, __, context) => {
// Roll back if server rejected
queryClient.setQueryData(['todos'], context?.previous);
toast.error('Failed to update');
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});
return (
);
}
```
---
## Pattern 3: Error States
```tsx
// Three levels of error granularity
// 1. Field-level error (form validation)
);
}
```
---
## Pattern 4: Success States
```tsx
// Inline success (after form submit)
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const { mutate } = useMutation({ onSuccess: () => setSubmitted(true) });
if (submitted) {
return (
Message sent!
We'll get back to you within 24 hours.
);
}
return ;
}
// Toast notifications (transient, non-blocking)
// Use for: mutations that succeed, background operations, non-critical info
// Do NOT use for: errors that need action, important info they might miss
import { toast } from 'sonner';
onSuccess: () => toast.success('Project created'),
onError: () => toast.error('Failed to create project. Try again.'),
```
---
## Pattern 5: Confirmation Dialogs (Destructive Actions)
```tsx
// Rule: confirm before any action that is hard or impossible to reverse
// Delete, disconnect, cancel subscription, overwrite data
function DeleteProjectButton({ projectId, projectName }: Props) {
const [open, setOpen] = useState(false);
const { mutate: deleteProject, isPending } = useDeleteProject();
return (
<>
>
);
}
```
**Confirmation dialog rules:**
- Title: names the specific thing being deleted ("Delete 'My Project'?", not "Delete?")
- Description: what happens + irreversibility ("cannot be undone")
- Cancel: always on the left, always the default focused element
- Confirm: right, danger variant, exact same wording as the trigger button
- Never auto-submit on Enter — require deliberate click
---
## Pattern 6: Progressive Disclosure
Show only what's needed; reveal more on demand.
```tsx
// "Show advanced options" — reveals expert controls without cluttering default UI
function CreateProjectForm() {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
);
}
```
---
## Decision Table
| Situation | Pattern |
|-----------|---------|
| List has no items yet | EmptyState with primary CTA |
| Search returned nothing | EmptyState with "clear filters" action |
| Content loading > 300ms | Skeleton screen |
| Button action in progress | Button with spinner + `aria-busy` |
| Toggle / checkbox | Optimistic UI |
| Data fetch failed | Inline error + retry button |
| Form submit failed | Field errors + top-level summary |
| Mutation succeeded | Toast (transient) or inline success |
| Destructive action | Confirmation dialog first |
| Complex form | Progressive disclosure for advanced fields |
---
## Checklist
- [ ] Every list has an empty state (not just blank space)
- [ ] Every async operation has a loading state (skeleton or spinner)
- [ ] Every error has a retry mechanism
- [ ] Success is acknowledged (toast or inline state change)
- [ ] Destructive actions guarded by confirmation dialog
- [ ] Confirmation dialogs name the specific item being affected
- [ ] No spinner shown for optimistic actions that should feel instant
- [ ] Progressive disclosure used for forms with >6 fields
- [ ] Error messages say what happened AND what to do (not just "Error")