Design & UX No. 353

Empty and Error States skill for Claude Code

No blank screens, no endless spinners, no dead ends. Before building the happy path, Claude fills a state matrix for each view covering first use, no results, loading, partial data, errors by HTTP status, offline, forbidden and not found. Each state gets a layout, plain-language copy and a next action, implemented through one consistent pattern per data source and verified by simulating every status with MSW and network throttling.

  • nameempty-error-states
  • size191 lines · ≈2,200 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and implements every non-happy state in an app — first-use and no-results empty states, loading with delay thresholds and skeletons, partial data, inline and page-level errors mapped from HTTP status, offline, permission denied, not found, and success — with a state matrix so none are missed. Use when building a new view or data fetch, when users see blank screens, endless spinners, or "Something went wrong", or when auditing an app for missing states.

Try asking

  • Our app shows a blank screen when a new user has no projects. Design proper empty states
  • Audit this dashboard for missing loading and error states
  • Replace our generic 'Something went wrong' errors with messages that help people recover

Use it when

  • Building a new view backed by data fetching
  • Users report blank screens, flashing spinners, or vague errors
  • Auditing an app for missing states before launch
  • Designing onboarding for brand-new accounts

Skip it when

  • You only need wording for buttons and messages, use ux-microcopy instead
  • You need server-side error handling and logging design rather than UI states

How to check the result

Every cell of the state matrix is implemented or marked N/A, and each one is reproduced by throttling, request blocking, MSW status handlers, offline mode and a fresh account.

The common mistake it prevents

Checking items.length === 0 before the request resolves, which flashes the empty state on every single page load.

~/.claude/skills/empty-error-states/SKILL.md

--- name: empty-error-states description: Designs and implements every non-happy state in an app — first-use and no-results empty states, loading with delay thresholds and… license: MIT ---

States are done when every data-driven view has a designed answer for: nothing yet, nothing matches, still loading, partly loaded, failed, offline, and not allowed; each answer tells the user what happened and what to do next; and no state is a blank area, an infinite spinner, or a raw error code.

Ground rules

  • Enumerate states before building the happy path. Most production bugs users see are missing states, not wrong happy paths.
  • Every non-happy state has a next action: create, clear filters, retry, sign in, request access, go back, contact support.
  • Errors are local. A failed widget shows an error in the widget; the page stays usable.
  • Preserve user input and context through every error.
  • Loading indicators must not flash: nothing under ~300ms, then hold for at least ~500ms once shown.

Workflow

- [ ] 1. Build the state matrix for each view
- [ ] 2. Design each state (layout, copy, action)
- [ ] 3. Implement a single state-switch pattern per data source
- [ ] 4. Map errors (HTTP status / exception type) to user messages
- [ ] 5. Simulate every state and verify

1. State matrix

Copy per view and fill every cell (or mark N/A with a reason):

StateTriggerWhat the user seesPrimary action
IdealData present——
Empty: first useAccount has no items everValue + how to startCreate first item / import
Empty: no resultsSearch/filter returns 0Echo query, suggest fixesClear filters
Empty: clearedUser completed or deleted allPositive confirmationNext logical step
Loading: initialFirst fetchSkeleton in final layout—
Loading: refreshRefetch with data shownKeep data, subtle indicator—
Loading: slow (> 10s)Long jobProgress + estimate, allow leavingNotify me when done
PartialSome sources failed or paginatedData + inline notice of what is missingRetry missing part
Error: recoverable5xx, timeout, networkWhat failed, data preservedRetry
Error: validation400/422Inline field errorsFix field
Offlinenavigator.onLine === false / fetch failsBanner, cached data if anyRetry when online (auto)
Unauthenticated401Session expired messageSign in (return to same page)
Forbidden403Why, who can grant accessRequest access
Not found404It was moved/deleted or never existedGo to list / search
Rate limited429Wait timeRetry after N seconds
Too much dataResult exceeds limitShow first N, explainNarrow filters / export
SuccessMutation completedConfirmation proportional to the actionUndo / view result

2. Design each state

Empty state anatomy (first use):

[Optional small illustration or product preview — skip generic clip art]
Headline: what this space is for         "Track every invoice in one place"
One sentence: the benefit or how it fills "Invoices you create or import appear here."
Primary action                           [Create invoice]
Secondary action                         Import from CSV · Watch 2-min demo
  • Prefer showing a preview of what the filled state looks like (sample row, ghosted chart) over a mascot.
  • No-results states echo the query and offer specific fixes: “No invoices match ‘acme’ in Paid. [Search all statuses] [Clear filters]”.
  • Keep empty states the same height as the populated view to avoid layout jumps.

Error message anatomy: what happened + why (if known) + what to do, in plain language, placed next to the cause.

BeforeAfter
“Error 500”“We couldn’t load your invoices. This is on our side. [Try again]”
“Invalid input”“Enter a due date after the issue date (Mar 3, 2026).”
“Forbidden”“Only workspace admins can change billing. Ask Dana Kim (admin) for access. [Request access]”
“Network error”“You’re offline. Changes are saved on this device and will sync when you reconnect.”

Wording details live in ux-microcopy.

Loading thresholds:

Expected waitPattern
< 300msNothing; avoid flashing indicators
300ms – 1sSubtle inline indicator (button spinner, progress bar at top)
1s – 10sSkeleton matching final layout, or spinner with label for actions
> 10sDeterminate progress with estimate; let the user leave and notify on completion

3. Implement one pattern per data source

React with TanStack Query (v5):

function InvoiceList({ filters }: { filters: Filters }) {
  const q = useQuery({ queryKey: ["invoices", filters], queryFn: () => fetchInvoices(filters) });

  if (q.isPending) return <InvoiceListSkeleton rows={8} />;
  if (q.isError) return <InlineError error={q.error} onRetry={() => q.refetch()} />;
  if (q.data.items.length === 0)
    return hasActiveFilters(filters) ? <NoResults filters={filters} /> : <FirstUseEmpty />;

  return (
    <>
      {q.isFetching && <TopProgress />}           {/* refresh: keep data visible */}
      {q.data.partial && <PartialNotice missing={q.data.missing} />}
      <InvoiceTable items={q.data.items} />
    </>
  );
}
  • Use an error boundary per region (not one at the app root) so a render crash in one widget does not blank the page.
  • With Suspense, place boundaries around independent regions; a single page-level boundary recreates the full-page spinner problem.
  • Delay skeleton display (~300ms) for fast endpoints to avoid a flash; many apps do this with a small useDelayedFlag hook or a CSS animation-delay on the skeleton fade-in.

4. Map errors to messages

export function toUserError(err: unknown): { title: string; action: "retry" | "signin" | "request" | "back" | "none" } {
  const status = (err as { status?: number })?.status;
  if (!navigator.onLine) return { title: "You're offline.", action: "retry" };
  switch (status) {
    case 401: return { title: "Your session expired. Sign in to continue.", action: "signin" };
    case 403: return { title: "You don't have access to this.", action: "request" };
    case 404: return { title: "This item was moved or deleted.", action: "back" };
    case 409: return { title: "Someone else changed this. Reload to see the latest version.", action: "retry" };
    case 429: return { title: "Too many requests. Try again in a minute.", action: "retry" };
    default:  return { title: "We couldn't complete that. Try again.", action: "retry" };
  }
}

Log the technical detail (request ID, status, endpoint) and show the request ID in the UI for support (“Reference: req_8f2a…”) on server errors.

Forms

  • Validate on submit first, then on blur/change for fields already shown as invalid.
  • Inline error below the field, linked with aria-describedby; set aria-invalid="true".
  • On submit failure with several errors, show a summary at the top that links to each field, and move focus to it.
  • Never clear the form on error.

Gotchas

  • items.length === 0 before data arrives renders the empty state for a split second on every load; check the pending state first.
  • A retry button that retries instantly against a down service causes hammering; add backoff or disable for a few seconds.
  • Toasts for errors that need action disappear before users read them; keep actionable errors inline and persistent.
  • Skeletons that do not match the final layout cause CLS when content arrives.
  • Optimistic updates need a rollback state and a visible message when the server rejects.
  • “Something went wrong” with no action is a dead end; there is always at least “Try again” or “Contact support” with the reference ID.
  • Accessible announcements: wrap async status messages in an aria-live="polite" region (errors that block progress can use role="alert").

Verification

  • Simulate each state: DevTools network throttling (Slow 4G) and request blocking, an MSW handler per status code (http.get(url, () => new HttpResponse(null, { status: 403 }))), offline mode, a fresh account, and a filter with no matches.
  • Storybook (or equivalent) story per state for shared components.
  • Every cell in the state matrix is implemented or explicitly N/A.
  • Screen reader announces loading completion and errors once, not repeatedly.

Output contract

## States: <view>
| State | Implemented | Component | Copy | Action |
|---|---|---|---|---|
| First use | yes | FirstUseEmpty | "Track every invoice…" | Create invoice |
| No results | yes | NoResults | echoes query | Clear filters |
| Error 5xx | yes | InlineError | "We couldn't load…" + ref ID | Retry |
| 403 | N/A | — | route guarded server-side | — |
Simulated with: MSW handlers in src/mocks/states.ts

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc