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.
From clarcnpx claudepluginhub marvinrichter/clarc --plugin clarcThis skill uses the workspace's default tool permissions.
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Enables AI agents to execute x402 payments with per-task budgets, spending controls, and non-custodial wallets via MCP tools. Use when agents pay for APIs, services, or other agents.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
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
// 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 (
<div className="flex flex-col items-center justify-center py-16 text-center max-w-sm mx-auto gap-4">
{icon && (
<div className="w-12 h-12 rounded-full bg-surface-overlay flex items-center justify-center text-text-secondary">
{icon}
</div>
)}
<div className="space-y-1">
<h3 className="text-base font-semibold text-text-primary">{title}</h3>
{description && (
<p className="text-sm text-text-secondary">{description}</p>
)}
</div>
{(action || secondaryAction) && (
<div className="flex gap-2">
{action && (
<Button onClick={action.onClick}>{action.label}</Button>
)}
{secondaryAction && (
<Button variant="ghost" onClick={secondaryAction.onClick}>
{secondaryAction.label}
</Button>
)}
</div>
)}
</div>
);
}
// Usage: three distinct contexts
<EmptyState
icon={<InboxIcon />}
title="No notifications yet"
description="When someone mentions you or comments on your work, it'll show up here."
/>
<EmptyState
icon={<SearchIcon />}
title="No results for “{query}”"
description="Try different keywords or remove filters."
action={{ label: 'Clear filters', onClick: clearFilters }}
/>
<EmptyState
icon={<FolderIcon />}
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:
// 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 (
<div className="p-4 border border-border rounded-lg space-y-3 animate-pulse">
{/* Header: avatar + name */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-surface-overlay" />
<div className="h-4 w-32 rounded bg-surface-overlay" />
</div>
{/* Body: two lines of text */}
<div className="space-y-2">
<div className="h-3 w-full rounded bg-surface-overlay" />
<div className="h-3 w-3/4 rounded bg-surface-overlay" />
</div>
{/* Footer */}
<div className="h-3 w-24 rounded bg-surface-overlay" />
</div>
);
}
// Usage with TanStack Query
function ProjectList() {
const { data: projects, isLoading } = useProjects();
if (isLoading) {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid grid-cols-3 gap-4">
{projects?.map(p => <ProjectCard key={p.id} project={p} />)}
</div>
);
}
// 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 (
<svg
className={`${sizeClass} animate-spin text-current`}
fill="none" viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
// 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<Todo[]>(['todos']);
// Optimistically flip the checkbox
queryClient.setQueryData<Todo[]>(['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 (
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.done ? 'line-through text-text-secondary' : ''}>
{todo.text}
</span>
</label>
);
}
// Three levels of error granularity
// 1. Field-level error (form validation)
<div>
<input aria-invalid={!!error} aria-describedby="email-error" ... />
{error && (
<p id="email-error" className="text-sm text-error mt-1" role="alert">
{error.message}
</p>
)}
</div>
// 2. Component-level error (query failed)
function ProjectList() {
const { data, error, refetch } = useProjects();
if (error) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<AlertCircleIcon className="w-8 h-8 text-error" />
<p className="text-sm text-text-secondary">
Failed to load projects. {error.message}
</p>
<Button variant="secondary" size="sm" onClick={() => refetch()}>
Try again
</Button>
</div>
);
}
// ...
}
// 3. Page-level error (route/boundary)
// In Next.js: error.tsx catches unhandled errors in a route segment
// app/dashboard/error.tsx
'use client';
export default function DashboardError({
error, reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="text-sm text-text-secondary max-w-md text-center">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
);
}
// Inline success (after form submit)
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const { mutate } = useMutation({ onSuccess: () => setSubmitted(true) });
if (submitted) {
return (
<div className="flex flex-col items-center gap-3 py-8 text-center">
<CheckCircleIcon className="w-10 h-10 text-success" />
<h3 className="font-semibold">Message sent!</h3>
<p className="text-sm text-text-secondary">
We'll get back to you within 24 hours.
</p>
</div>
);
}
return <form onSubmit={...}>...</form>;
}
// 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.'),
// 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 (
<>
<Button variant="danger" onClick={() => setOpen(true)}>
Delete project
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete “{projectName}”?</DialogTitle>
<DialogDescription>
This will permanently delete the project and all its data.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="danger"
loading={isPending}
onClick={() => deleteProject(projectId, { onSuccess: () => setOpen(false) })}
>
Delete project
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
Confirmation dialog rules:
Show only what's needed; reveal more on demand.
// "Show advanced options" — reveals expert controls without cluttering default UI
function CreateProjectForm() {
const [showAdvanced, setShowAdvanced] = useState(false);
return (
<form>
{/* Always visible: core fields */}
<Input label="Project name" name="name" />
<Textarea label="Description" name="description" />
{/* Progressive disclosure: advanced settings */}
<button
type="button"
className="flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary"
aria-expanded={showAdvanced}
onClick={() => setShowAdvanced(!showAdvanced)}
>
<ChevronIcon className={showAdvanced ? 'rotate-90' : ''} aria-hidden />
Advanced settings
</button>
{showAdvanced && (
<div className="space-y-4 border-l-2 border-border pl-4">
<Select label="Visibility" name="visibility" />
<Input label="Custom domain" name="domain" />
</div>
)}
<Button type="submit">Create project</Button>
</form>
);
}
| 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 |