Postbox
Design system
Every specimen below is the real component with the real tokens, imported from the same files the product imports. It cannot drift, which is the only reason to keep it.
The one rule: use the raw shadcn component. If you think you need a custom one, you almost certainly need a variant of an existing one. The other seven, and the reasoning, are in docs/design-system.md.
Colour
Tailwind's olive scale, top to bottom — a warm near-neutral, so the same family gives both the neutrals and the accent. Never write a colour; use the token. It flips correctly in dark mode.
background
the page
foreground
text
card
raised
muted
quiet surface
primary
one action per view
accent
hover, selected
border
hairlines
destructive
irreversible only
success
status only
warning
status only
Type
Geist, self-hosted. Monospace for anything read character by character — addresses, domains, DNS values. One h1 per screen; the size comes from a class, the level from the structure.
text-3xl font-semibold
onboarding title
Create your inbox
text-2xl font-semibold
page title
Settings
text-[0.9375rem] font-medium
section title
Aliases
text-sm
body
Real email hosting for your domain.
text-[0.8125rem]
UI, dense rows
Your invoice for March
text-xs text-muted-foreground
meta
5 unread · 6 messages
font-mono
anything read character by character
postbox-verification=a1b2c3d4
tabular
numbers that update in place
18:42 · 10 GB · 150
Space
Multiples of 4px, from a short list of steps — and always as gap on the parent, never a margin on a child to fix its parent's layout. If a value is not on this list, the layout is asking the wrong question.
parts of one control
a field and its label
sibling controls
fields in a form
blocks in a section
one section from the next
Don't
Do
A margin on a child puts the spacing rule in the wrong file. It collapses, doubles, or survives after the sibling it was compensating for is deleted. Space belongs to the container.
Shape
Radius is a function of what a thing is, not where it sits. Elevation is not decoration: a hairline border is the default separation, and a shadow means this floats above the page — so it belongs only to things that actually do.
rounded-lg
controls
rounded-xl
xl controls, small panels
rounded-2xl
a panel that is the subject
rounded-full
pills, avatars
border
separation — the default
shadow
only things that float
Icons
Lucide, and the component sizes them — size-4 is already in the button base class. An icon beside text is aria-hidden; an icon on its own needs a label on the control, naming the action rather than the picture.
size-3.5
dense rows, xs/sm controls
size-4
the default — controls, beside text
size-5
where the icon is the subject
Don't
Do
The label belongs to the control, not the glyph, and it names what happens — a screen reader announcing “Archive icon, button” has described the decoration instead of the action.
Status
The one place colour carries meaning: a column of DNS states has to be readable at a glance. Four states, four Badge variants — and never colour alone, so each carries its own icon and word.
DnsStatusDot
where there is no room for a badge
Badge
every stock variant
Incorrect is amber, not red. A mistyped DNS record is a waiting state, not a failure. destructive is reserved for actions that destroy data. success and warning are the only additions Postbox makes to a registry component — see the extension note in badge.tsx.
Lists
Every list of things — messages, mailboxes, aliases, domains, sessions — is an ItemGroup of Items. Not a <li> with a hand-built flex layout.
ItemGroup / Item variant=outline
delivers to hello@example.test
delivers to hello@example.test
Item + ItemMedia
a message row
Hi, March invoice attached. Same terms as last month.
Table
a tabular record — DNS
| Type | Name | Value | Status |
|---|---|---|---|
| MX | @ | 10 mx.postbox.test | Connected |
| TXT | _dmarc | v=DMARC1; p=none | Waiting |
EmptyState
every empty list, without exception
Triage
A list is a thing you work through, so opening something from it must not take it away. Actions on a row are instant and unconfirmed — which is only defensible because every one of them is undoable.
row actions
replace the timestamp on hover, so nothing reflows
undo
the toast is the discoverable half; z is the fast half
shortcuts
one owner per key — see docs/design-system.md
- Move the cursor
- jk
- Open beside the list
- Enter
- Archive
- e
- Undo
- z
Don't
router.push(`/app/${folder}/${id}`) // replaces the listDo
// list in [folder]/layout.tsx, thread in childrenLayouts do not re-render on navigation, so putting the list in one keeps its scroll position, its selection and its cursor while the thread renders beside it. The URL still changes, which is what keeps deep links and the back button working.
Forms
Field wraps every input. An error replaces the hint rather than stacking under it, and it says what to do next.
Field + Input
Just the domain — no https://, no www needed.
Field data-invalid
InputGroup + inline-end addon
a prefix or suffix
InputGroup + icon and button
search
ToggleGroup
mutually exclusive chips
Progress
storage
Feedback
Errors are sentences written for the person reading them, and they say what to do next. Never a code, never a stack.
Alert
Skeleton
layout, while loading
Spinner
inside a control
Kbd
a keyboard hint
Mistakes that shipped
Each of these was in the product. They are here because a rule with a real example attached is one people remember.
Don't
Do
Never leave a native control unstyled. type="search" gets Chrome's own clear button — an unstyled blue glyph that ignores the theme, cannot be restyled reliably, and has no accessible name you control. Use type="text" and an InputGroupButton.
Don't
Do
One control, not two. An input beside a box means the focus ring draws around half the thing and the borders never quite meet. It looked broken because it was two fields. InputGroup exists for exactly this.
Don't
Do
State is a Badge. A span with colour classes is a component nobody can find, restyle, or keep consistent. The variant carries the meaning, and it flips correctly in dark mode.
Don't
Do
Compose, do not restyle. Six hand-rolled buttons tracking their own selected state, when ToggleGroup does it. The pressed state here is quieter than the custom one — which is the component telling you these are suggestions, not a mode switch.
Don't
Do
- Domain
- Verify
- Inbox
A custom primitive, but a documented one. The registry has no Stepper, so this is the rare justified custom — and it is written in the shadcn idiom, registered in the doc with its reason, and shows which steps are done rather than only which one is current.