Docs
States
An interface is not finished until it handles loading, nothing, and failure. Every data block and form block takes one prop, state, and shows the right thing.
The edge cases are what make an app feel built. They are also what generated UI skips. Here they are part of the block: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers. Open any block in the gallery and use the State chips above the preview to see each one.
The states
loading: Data is on its way. A skeleton with the same shape as the content, announced as loading.empty: There is nothing yet. A clear message and, where it helps, a way to make the first thing.error: It failed. A message that says what happened and a way to try again.submitting: A form request is in flight. Fields lock, the button spins, and nothing can be sent twice.success: The request went through. The confirmation the flow ends on.
Leave state out and the block behaves as it always has. Nothing changes until you pass it.
Data blocks
Lists, boards, dashboards, calendars and chats take state, onRetry, emptyState and errorState. useResource turns a fetch into the state to pass:
import { Mail1 } from "@/components/ballmac/blocks/mail-1/mail-1"
import { useResource } from "@/lib/ballmac/use-resource"
export function Inbox() {
const mail = useResource((signal) => fetch("/api/mail", { signal }).then((r) => r.json()))
return (
<Mail1
state={mail.state} // "loading" | "ready" | "empty" | "error"
messages={mail.data}
onRetry={mail.retry}
emptyState={{ title: "Inbox zero", description: "Nothing new. Enjoy it." }}
errorState={{ description: "Mail is taking a break. Your messages are safe." }}
/>
)
}useResource aborts a request that is replaced or unmounted, never lets a slow old answer overwrite a newer one, and goes round again on retry(). If you already use TanStack Query or SWR, map their result instead:
import { stateOf } from "@/lib/ballmac/use-resource"
const query = useQuery({ queryKey: ["mail"], queryFn: fetchMail })
<Mail1 state={stateOf(query)} messages={query.data} onRetry={query.refetch} />Form blocks
Sign-in, sign-up, contact, newsletter, settings and the rest already run their own submit flow. state lets you drive it from your own async code, or show one stage on its own: submitting locks the fields, success shows the confirmation, error shows the failure. errorState.description sets the message.
const [phase, setPhase] = useState<"ready" | "submitting" | "success" | "error">("ready")
<Signup1
state={phase}
errorState={{ description: "That email is already registered." }}
onSubmit={async (values) => {
setPhase("submitting")
try { await createAccount(values); setPhase("success") } catch { setPhase("error") }
}}
/>Leaving state out lets the form decide by itself, exactly as before. Several forms also take defaultValues or defaultEmail to prefill.
Your own views
StateView is the part the blocks are built on. Wrap anything that loads:
<StateView
state={invoices.state}
loading={<InvoiceSkeleton />} // the same shape as the real list
empty={{ title: "No invoices", action: <Button>Create one</Button> }}
error={{ title: "Invoices are unavailable", details: String(invoices.error) }}
onRetry={invoices.retry}
>
<InvoiceList items={invoices.data} />
</StateView>What it does for accessibility
- Loading is announced as a status, and the region is marked
aria-busywhile it lasts. The skeleton itself is hidden from assistive technology. - An error is announced as an alert. Focus is never moved for you, so nobody loses their place.
- Under reduced motion the fades stop. The skeleton's pulse follows the theme's decorative loops switch, and the fades follow its speed.
- A form that is submitting is a disabled fieldset, so every control inside is disabled, the same way the browser does it.
Which blocks
23 of the 63 free blocks show states. The rest are static content with nothing to load, submit or fail, and say so on their page.
- AI Chat 1: full chat app pane: loading, empty, error
- AI Chat 2: conversation with artifact panel: loading, empty, error
- Billing 1: plan, usage and invoices: loading, empty, error
- Billing 2: change plan with proration: loading, empty, error
- Blog 1: featured post with filterable grid: loading, empty, error
- Calendar 1: week view with mini month: loading, empty, error
- Changelog 1: release notes timeline: loading, empty, error
- Contact 1: form with contact methods: submitting, success, error
- Dashboard 1: sales overview: loading, empty, error
- Dashboard 2: traffic analytics: loading, empty, error
- Forgot Password 1: request, resend and reset: submitting, success, error
- Invite 1: accept a team invitation: submitting, success, error, expired
- Kanban 1: project board with drag and keyboard moves: loading, empty, error
- Login 1: card with SSO and magic link: submitting, error
- Login 2: split screen with testimonial: submitting, error
- Mail 1: three-pane inbox: loading, empty, error
- Newsletter 1: signup with topics and sample issue: submitting, success, error
- Onboarding 1: workspace setup wizard: submitting, success, error
- Settings 1: profile and account: loading, submitting, success, error
- Settings 2: notification preferences: loading, submitting, success, error
- Settings 3: team members and roles: loading, empty, error
- Signup 1: form with benefits column: submitting, success, error
- Verify 1: one-time code with resend: submitting, success, error
A build check keeps this true: a free block that lists no states and gives no reason fails pnpm check, and so does a state example that would break a production build.