Shows the right thing for what the data is doing: a skeleton while loading, a helpful empty state, an error with retry, or your content. Announced to screen readers, never steals focus, fades with the theme's motion.
v1.0.1Updated Oct 12, 2026Ships with a testUpdate without losing your edits
What changed
- 1.0.1 Oct 12, 2026Moves frameProps into its own frame-props module so server components can use it; state-view still exports it.
- 1.0.0 Oct 12, 2026First release.
Recent activity
"use client"
import * as React from "react"
import { Plus } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { StateView } from "@/components/ballmac/state-view"
import type { BlockState } from "@/lib/ballmac/use-resource"
const activity = [
{ who: "Maya Jensen", what: "approved the Q4 launch plan", when: "2m ago", tone: "bg-chart-2" },
{ who: "Dev Patel", what: "commented on Pricing page", when: "18m ago", tone: "bg-chart-1" },
{ who: "Sana Okafor", what: "shipped build 482 to production", when: "1h ago", tone: "bg-chart-3" },
{ who: "Lucas Meyer", what: "invited 3 teammates", when: "3h ago", tone: "bg-chart-4" },
]
/** The skeleton has the same rows, in the same places, as the ready list, so the page does not jump when data arrives. */
function ActivitySkeleton() {
return (
<ul className="divide-y" aria-hidden="true">
{activity.map((a) => (
<li key={a.who} className="flex items-center gap-3 py-3">
<Skeleton className="size-8 shrink-0 rounded-full" />
<div className="grid flex-1 gap-1.5">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3 w-1/4" />
</div>
</li>
))}
</ul>
)
}
export default function StateViewDemo() {
const [state, setState] = React.useState<BlockState>("ready")
return (
<div className="bg-card w-full max-w-lg rounded-2xl border p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">Recent activity</h3>
<SegmentedControl aria-label="State" value={state} onValueChange={(v) => setState(v as BlockState)}>
<SegmentedControlItem value="ready">Ready</SegmentedControlItem>
<SegmentedControlItem value="loading">Loading</SegmentedControlItem>
<SegmentedControlItem value="empty">Empty</SegmentedControlItem>
<SegmentedControlItem value="error">Error</SegmentedControlItem>
</SegmentedControl>
</div>
<StateView
state={state}
loading={<ActivitySkeleton />}
empty={{
title: "No activity yet",
description: "Invite your team or publish something and it will show up here.",
action: (
<Button size="sm">
<Plus /> Invite teammates
</Button>
),
}}
error={{ title: "Could not load activity", description: "The activity service did not answer in time.", details: "GET /v1/activity 504 Gateway Timeout (request 8f2c1e)" }}
onRetry={() => setState("loading")}
>
<ul className="divide-y">
{activity.map((a) => (
<li key={a.who} className="flex items-center gap-3 py-3 text-sm">
<span aria-hidden="true" className={`${a.tone} text-background flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold`}>
{a.who
.split(" ")
.map((n) => n[0])
.join("")}
</span>
<p className="min-w-0 flex-1">
<span className="font-medium">{a.who}</span> <span className="text-muted-foreground">{a.what}</span>
</p>
<span className="text-muted-foreground text-xs">{a.when}</span>
</li>
))}
</ul>
</StateView>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/state-viewInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/empty @ballmac/skeleton @ballmac/i18n @ballmac/use-resource @ballmac/frame-propsCopy the source into your project.
components/ballmac/state-view.tsx// Ballmac UI: State View. https://ui.ballmac.com/components/state-view "use client" import * as React from "react" import { CircleAlert, Inbox, RotateCw } from "lucide-react" import { Button } from "@/components/ballmac/button" import { Empty, EmptyAction, EmptyDescription, EmptyMedia, EmptyTitle } from "@/components/ballmac/empty" import { Skeleton } from "@/components/ballmac/skeleton" import { useMessages } from "@/lib/ballmac/i18n" import { frameProps } from "@/lib/ballmac/frame-props" import { isBusy, type BlockState } from "@/lib/ballmac/use-resource" import { cn } from "@/lib/utils" type EmptyConfig = { title?: string description?: string icon?: React.ReactNode /** The way out of the empty state: usually a button that creates the first item. */ action?: React.ReactNode } type ErrorConfig = { title?: string description?: string /** Technical detail for people who want it, shown behind a disclosure. */ details?: string retryLabel?: string /** Replace the retry button, for example with a link to status. */ action?: React.ReactNode } type StateViewProps = Omit<React.ComponentProps<"div">, "children"> & { /** What to show. `submitting` and `success` belong to forms and render the children. */ state: BlockState /** Shown while loading. Make it the same shape as the ready content so nothing jumps. A plain set of lines is used if you leave it out. */ loading?: React.ReactNode /** Copy and action for the empty state, or your own node. */ empty?: EmptyConfig | React.ReactNode /** Copy for the error state, or your own node. */ error?: ErrorConfig | React.ReactNode /** Called by the retry button. Without it the button is not drawn. */ onRetry?: () => void /** Text announced to assistive technology while loading. */ label?: string /** Drop the empty and error panels' border and tint, for use inside a frame that already has them. */ bare?: boolean /** The ready content. */ children?: React.ReactNode } /** The props every block with states takes, so they all behave the same way. */ type StateProps = { /** What the block shows. Defaults to `ready`. */ state?: BlockState /** Called by the retry button in the error state. Without it the button is not drawn. */ onRetry?: () => void /** Copy and action for the empty state. */ emptyState?: EmptyConfig /** Copy for the error state. */ errorState?: ErrorConfig } const isNode = (v: unknown): v is React.ReactElement => React.isValidElement(v) /** Placeholder lines for when no skeleton is given. */ function DefaultSkeleton() { return ( <div className="grid gap-3" aria-hidden="true"> <Skeleton className="h-5 w-1/3" /> <Skeleton className="h-4 w-full" /> <Skeleton className="h-4 w-11/12" /> <Skeleton className="h-4 w-4/5" /> </div> ) } /** * Shows the right thing for what the data is doing: a skeleton while loading, a helpful empty state, an error with a way * to try again, or your content when it is ready. It announces every change to screen readers, marks the region busy while * loading, fades between states with the theme's motion, and never moves focus on its own. */ function StateView({ state, loading, empty, error, onRetry, label, bare = false, className, children, ...props }: StateViewProps) { const msg = useMessages() const e: EmptyConfig = isNode(empty) ? {} : ((empty as EmptyConfig | undefined) ?? {}) const x: ErrorConfig = isNode(error) ? {} : ((error as ErrorConfig | undefined) ?? {}) const view = state === "loading" || state === "empty" || state === "error" ? state : "ready" return ( <div data-slot="state-view" data-state={state} aria-busy={isBusy(state) || undefined} className={cn("min-w-0", className)} {...props} > {/* keyed by the view, so each change plays the enter animation; durations follow the theme's speed */} <div key={view} className="animate-in fade-in-0 slide-in-from-bottom-1 h-full motion-reduce:animate-none [--tw-enter-translate-y:calc(var(--bm-distance,10px)*0.4)]" > {view === "loading" ? ( <div role="status" data-slot="state-view-loading" className="h-full"> <span className="sr-only">{label ?? msg("state-view.loading", "Loading")}</span> {loading ?? <DefaultSkeleton />} </div> ) : view === "empty" ? ( isNode(empty) ? ( empty ) : ( <Empty data-slot="state-view-empty" className={cn("h-full bg-card/60", bare && "border-0 bg-transparent")}> <EmptyMedia>{e.icon ?? <Inbox />}</EmptyMedia> <EmptyTitle>{e.title ?? msg("state-view.emptyTitle", "Nothing here yet")}</EmptyTitle> <EmptyDescription>{e.description ?? msg("state-view.emptyDescription", "When there is something to show, it will appear here.")}</EmptyDescription> {e.action ? <EmptyAction>{e.action}</EmptyAction> : null} </Empty> ) ) : view === "error" ? ( isNode(error) ? ( error ) : ( <div role="alert" data-slot="state-view-error" className={cn("border-destructive/25 bg-destructive/[0.04] flex h-full w-full min-w-0 flex-col items-center justify-center rounded-xl border px-5 py-10 text-center", bare && "border-0 bg-transparent")} > <span aria-hidden="true" className="bg-destructive/10 text-destructive mb-4 flex size-11 items-center justify-center rounded-full [&>svg]:size-5"> <CircleAlert /> </span> <h3 className="text-sm font-semibold">{x.title ?? msg("state-view.errorTitle", "Something went wrong")}</h3> <p className="text-muted-foreground mt-1 max-w-sm text-sm leading-relaxed"> {x.description ?? msg("state-view.errorDescription", "We could not load this. Check your connection and try again.")} </p> {x.details ? ( <details className="text-muted-foreground mt-3 max-w-full text-xs"> <summary className="focus-visible:ring-[3px] focus-visible:ring-ring/50 cursor-default rounded-sm outline-none select-none">{msg("state-view.details", "Details")}</summary> <pre className="bg-muted mt-2 max-w-full overflow-auto rounded-md p-2 text-start font-mono whitespace-pre-wrap">{x.details}</pre> </details> ) : null} {x.action || onRetry ? ( <div className="mt-5 flex flex-wrap items-center justify-center gap-2"> {onRetry ? ( <Button variant="outline" size="sm" onClick={onRetry}> <RotateCw aria-hidden="true" /> {x.retryLabel ?? msg("state-view.retry", "Try again")} </Button> ) : null} {x.action} </div> ) : null} </div> ) ) : ( children )} </div> </div> ) } export { StateView, frameProps, type StateViewProps, type StateProps, type EmptyConfig, type ErrorConfig }Update the import paths to match your project setup.
Usage
import { StateView, frameProps } from "@/components/ballmac/state-view"The full example is in the Code tab above.
Examples
With a real fetch
Invoices
"use client"
import * as React from "react"
import { RefreshCw } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
import { Skeleton } from "@/components/ballmac/skeleton"
import { StateView } from "@/components/ballmac/state-view"
import { useResource } from "@/lib/ballmac/use-resource"
type Scenario = "normal" | "empty" | "fail"
type Invoice = { id: string; customer: string; amount: string; status: "Paid" | "Due" }
const invoices: Invoice[] = [
{ id: "INV-1042", customer: "Northwind", amount: "$2,400.00", status: "Paid" },
{ id: "INV-1043", customer: "Acme Co.", amount: "$860.00", status: "Due" },
{ id: "INV-1044", customer: "Globex", amount: "$12,150.00", status: "Paid" },
]
/** A fake API: waits, then answers the way the scenario says. Replace it with fetch(). */
function fakeApi(scenario: Scenario, signal: AbortSignal) {
return new Promise<Invoice[]>((resolve, reject) => {
const t = setTimeout(() => (scenario === "fail" ? reject(new Error("503 Service Unavailable")) : resolve(scenario === "empty" ? [] : invoices)), 1100)
signal.addEventListener("abort", () => clearTimeout(t))
})
}
export default function StateViewResource() {
const [scenario, setScenario] = React.useState<Scenario>("normal")
const invoicesResource = useResource((signal) => fakeApi(scenario, signal), { deps: [scenario] })
return (
<div className="bg-card w-full max-w-lg rounded-2xl border p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-semibold tracking-tight">Invoices</h3>
<div className="flex items-center gap-2">
<SegmentedControl aria-label="What the API returns" value={scenario} onValueChange={(v) => setScenario(v as Scenario)}>
<SegmentedControlItem value="normal">Data</SegmentedControlItem>
<SegmentedControlItem value="empty">None</SegmentedControlItem>
<SegmentedControlItem value="fail">Fails</SegmentedControlItem>
</SegmentedControl>
<Button variant="ghost" size="icon-sm" aria-label="Reload" onClick={invoicesResource.retry}>
<RefreshCw />
</Button>
</div>
</div>
<StateView
state={invoicesResource.state}
loading={
<div className="grid gap-2" aria-hidden="true">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-11 w-full" />
))}
</div>
}
empty={{ title: "No invoices", description: "Invoices appear here once you bill a customer." }}
error={{ title: "Invoices are unavailable", details: invoicesResource.error instanceof Error ? invoicesResource.error.message : undefined }}
onRetry={invoicesResource.retry}
>
<ul className="grid gap-2">
{invoicesResource.data?.map((inv) => (
<li key={inv.id} className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 text-sm">
<div className="min-w-0">
<p className="font-medium">{inv.customer}</p>
<p className="text-muted-foreground text-xs">{inv.id}</p>
</div>
<div className="text-end">
<p className="font-medium tabular-nums">{inv.amount}</p>
<p className={inv.status === "Paid" ? "text-chart-2 text-xs" : "text-muted-foreground text-xs"}>{inv.status}</p>
</div>
</li>
))}
</ul>
</StateView>
</div>
)
}API reference
<StateView>
| Prop | Type | Default |
|---|---|---|
state*What to show. `submitting` and `success` belong to forms and render the children. | BlockState | — |
loadingShown while loading. Make it the same shape as the ready content so nothing jumps. A plain set of lines is used if you leave it out. | React.ReactNode | — |
emptyCopy and action for the empty state, or your own node. | EmptyConfig | React.ReactNode | — |
errorCopy for the error state, or your own node. | ErrorConfig | React.ReactNode | — |
onRetryCalled by the retry button. Without it the button is not drawn. | () => void | — |
labelText announced to assistive technology while loading. | string | — |
bareDrop the empty and error panels' border and tint, for use inside a frame that already has them. | boolean | false |
childrenThe ready content. | React.ReactNode | — |
<State>
| Prop | Type | Default |
|---|---|---|
stateWhat the block shows. Defaults to `ready`. | BlockState | — |
onRetryCalled by the retry button in the error state. Without it the button is not drawn. | () => void | — |
emptyStateCopy and action for the empty state. | EmptyConfig | — |
errorStateCopy for the error state. | ErrorConfig | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen reader | Loading is announced as a status; an error is announced as an alert; the region is aria-busy while loading |
| Tab | Reaches the retry button and the details disclosure; focus is never moved for you |
Use with AI
Wrap any data view: <StateView state={state} loading={<MySkeleton/>} empty={{ title, description, action }} error={{ title, description, details }} onRetry={retry}>{ready content}</StateView>. Pair with useResource, which returns the state. Loading is announced and the region is aria-busy; errors use role=alert; focus is never moved. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI State View (@ballmac/state-view) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Any list, table, feed or panel that loads data
- Giving a block its loading, empty and error views without writing them each time
Not for
- Forms: they track submitting and success themselves (blocks with a state prop handle it)
- A single value that is just a number: use a skeleton line
Registry JSON: https://ui.ballmac.com/r/state-view.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Use Resource
A small fetch state machine that gives every block the state to show: loading, ready, empty or error, with retry, abort and stale-answer protection. Includes an adapter for TanStack Query and SWR.
Empty
A responsive empty-state surface with media, concise guidance, and a dedicated action area.
Skeleton
A theme-aware loading placeholder with an animation that stops when reduced motion is requested.
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.