--- 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)
{error && ( )}
// 2. Component-level error (query failed) function ProjectList() { const { data, error, refetch } = useProjects(); if (error) { return (

Failed to load projects. {error.message}

); } // ... } // 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 (

Something went wrong

{error.message}

); } ``` --- ## 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 ( <> Delete “{projectName}”? This will permanently delete the project and all its data. This action cannot be undone. ); } ``` **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 (
{/* Always visible: core fields */}