damon-ui
Browse items
uiemptyskeletonbutton

Resource state

Empty, error and loading states for lists, tables and pages: EmptyState, ErrorState (retry and reference id), and LoadingState (skeletons shaped like a table, card grid or list). Built on shadcn's Empty and Skeleton.

npx shadcn add @damon/resource-state

Without the @damon alias: npx shadcn add https://damon-ui.pages.dev/r/resource-state.json · raw markdown

Preview
No invoices yet
Invoices you create or import will show up here.
Loading invoices

Installs components/ui/resource-state.tsx and shadcn's empty, skeleton, button if missing. Exports EmptyState, ErrorState, LoadingState, ResourceState.

When to use

  • A list or table with no rows yet (EmptyState with the "create first" action), or no rows for the current filters (offer "Clear filters").
  • A fetch that failed (ErrorState with onRetry and the request id).
  • loading.tsx files and Suspense fallbacks (LoadingState variant="table"), so the page shape shows straight away.
  • Permission or "not set up yet" pages: ResourceState with your own icon.

When NOT to use

  • Inline messages next to content that did load. Use @damon/notice.
  • Loading a single button or field. Use a spinner in the button.
  • A full-screen 404/500. Use Next's not-found.tsx / error.tsx (they can render ErrorState inside).

Props

EmptyState / ResourceState

PropTypeDefaultNotes
titleReactNoderequiredWhat's missing: "No invoices yet".
descriptionReactNodeWhat will appear here and how.
actionReactNodeThe next step (buttons/links).
detailReactNodeSmall print under the actions.
iconReactNode | nullinbox (Empty) / none

ErrorState — all of the above plus:

PropTypeDefaultNotes
titleReactNode"This couldn't be loaded"
onRetry() => voidRenders a "Try again" button (ignored if action set).
retryLabelstring"Try again"
referencestringRequest/error id shown in monospace for support.

Renders with role="alert".

LoadingState

PropTypeDefaultNotes
variant"table" | "cards" | "list""table"Match the content that's coming.
rowsnumber5
labelstring"Loading"Screen-reader text (role="status", aria-busy).

Minimal example

import { EmptyState } from "@/components/ui/resource-state"

{invoices.length === 0 ? (
  <EmptyState title="No invoices yet" description="Invoices you create will show up here." action={<Button>New invoice</Button>} />
) : (
  <InvoiceTable rows={invoices} />
)}

Real example

app/invoices/loading.tsx, error.tsx and the filtered-empty case:

// loading.tsx
import { LoadingState } from "@/components/ui/resource-state"
export default function Loading() {
  return <LoadingState variant="table" rows={8} label="Loading invoices" />
}

// error.tsx
"use client"
import { ErrorState } from "@/components/ui/resource-state"
export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <ErrorState
      description="We couldn't reach the server. Your saved work is not affected."
      onRetry={reset}
      reference={error.digest}
    />
  )
}

// page.tsx, filters applied but nothing matches
<EmptyState
  icon={<SearchXIcon />}
  title="No invoices match these filters"
  description={`Nothing for “${query}” in ${monthLabel}.`}
  action={<Button variant="outline" asChild><Link href="/invoices">Clear filters</Link></Button>}
/>

Constraints

  • Distinguish "nothing yet" from "nothing matches": they need different words and different actions.
  • Error copy should say what failed and whether the user's data is safe, but only claim it's safe when that's true.
  • ErrorState works in server components. Passing onRetry (a function) needs a client component, as in error.tsx.
  • Skeleton widths vary on purpose so the placeholder reads as content.

Files

  • ui/resource-state.tsx