Patterns
Pages are compositions of the kit
A list page, a detail page, a settings section, an empty state and an error state each have one shape. Copy the shape; fill in the resource.
Page width
Every dashboard page renders inside PageContainer. It owns the wrapper, the padding, and one of three widths; a page never sets its own max-w. default (4xl) is everything; reading (3xl) is one message or feed; full is a padded tool page. Email and SMS siblings always share a size.
<PageContainer className="space-y-6">…</PageContainer>
<PageContainer width="reading">…</PageContainer>
<PageContainer width="full">…</PageContainer>
List page
Title and one-line subtitle on the left, the single primary action on the right. Below: filters (line tabs) left, search right. The table sits in an outline card and spans the full width; status is a Badge, numbers are right-aligned and tabular, row actions live at the end of the row. With no rows, the table is replaced by an EmptyState that repeats the primary action.
Domains
Send from your own address.
Detail page
The object's name as the title (mono when it is a machine value), one meta line under it, secondary actions on the right. A meta grid of overline label + value comes first, then one filled DetailSection per topic. Anything destructive is last, in a destructive-tone card.
acme.com
Verified · added 2 months ago
- Status
- Verified
- Region
- EU (Frankfurt)
- Sent (30d)
- 12,757
- Bounce rate
- 0.4%
Settings section
One filled Card per topic: title + description in the header, fields with labels above, Save right-aligned in the footer. Fields are h-8 pills; a two-column grid on wide screens collapses to one.
Empty and error states
Icon tile, a title that names what's missing, one sentence of why it matters, at most two actions (the first repeats the page's primary action). Errors say what happened and offer Retry.
Couldn't load contacts
The request timed out. Your data is safe.
Messaging (SMS) patterns
Phone numbers, reach, registration state, and skip reasons each render one way. The words come from @unitpost/contracts (reach table, skip vocabulary); these components own only how they look. DESIGN.md §3 lists the props and when to use each.
<PhoneNumber number="+14155550123" /> <PhoneNumber number={null} /> <PhoneNumber number="+1•••••0123" country="US" masked />- 🇬🇧United Kingdom
- 🇩🇪Germany
<ReachList kind="TOLL_FREE" country="US" /> <ReachList kind="ALPHANUMERIC" countries={[…]} max={3} /> <ReachList kind="ALPHANUMERIC" country={null} />- Registration status
- Needs changes
The regulator asked for a correction. Fix it and resubmit; approval usually follows within days.
- Submitted
- 12 Sep 2026
- The legal name does not match the registration certificate.
<RegistrationStatusBlock status="REQUIRES_UPDATES" labels={…} variants={…} help="…" feedback={[…]} source="regulator" onResubmit={…} extraFields={[{ label: "Submitted", value: "12 Sep 2026" }]} />Carriers usually answer within two business days.
- The contact email must be on the brand's website domain.
- Rate plan unavailable in this region.
<StatusCaption badge={<Badge variant="info">In review</Badge>} caption="…" /> <CarrierFeedback items={…} source="unitpost" />Request a sender for United Kingdom to reach these recipients.Open senders
Record consent from the contact page or via POST /contacts/{id}/sms-consent.
Inside the recipient's quiet hours. Resumes at 09:00 local.
Nothing to do; it sends when the local window opens.
The sender is pending registration, not active.
Fix or replace the sender, then resume the campaign.Open senders
<SkipReasonLine reason="out_of_reach" ctx={{ country: "GB", reach: ["US"] }} variant="badge | line | alert" />1 of 4 recipients reachable · 3 skipped · 1 excluded
5 recipients in this segment
<SmsRecipientReview state={preflight} target={{ campaignId }} excludedIds={ids} onExcludedChange={setIds} />