Ballmac UI home

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:

app/inbox.tsx
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-busy while 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.

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.