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.

gap-1.5

parts of one control

6px
gap-2

a field and its label

8px
gap-3

sibling controls

12px
space-y-4

fields in a form

16px
space-y-6

blocks in a section

24px
mt-10

one section from the next

40px

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

A panel sitting on the page.

shadow

only things that float

A menu floating above it.

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.

ConnectedWaitingIncorrectNot required

DnsStatusDot

where there is no room for a badge

Ownership MX SPF

Badge

every stock variant

DefaultSecondaryOutlineDestructiveGhost Success Warning

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

hi@example.test

delivers to hello@example.test

contact@example.test

delivers to hello@example.test

Item + ItemMedia

a message row

Jane Doe — Your invoice for March

Hi, March invoice attached. Same terms as last month.

18:42

Table

a tabular record — DNS

TypeNameValueStatus
MX@10 mx.postbox.testConnected
TXT_dmarcv=DMARC1; p=noneWaiting

EmptyState

every empty list, without exception

Nothing archived
Archiving clears your inbox without deleting anything.

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

Rob at The Hardware StoreSun
Order ready for collectionYour order is behind the counter whenever you are passing.

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 list

Do

// list in [folder]/layout.tsx, thread in children

Layouts 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

@example.test

InputGroup + icon and button

search

ToggleGroup

mutually exclusive chips

Progress

storage

x
3.8 of 10 GB

Buttons

One emphasised action per view, and the variant carries meaning rather than weight. Size is a prop: ask for a rung on the ladder, never write a height. Anything that navigates is a link with button styling — middle-click and open-in-new-tab have to keep working.

variant

what each one means, not how heavy it looks

size

xs 24 · sm 28 · default 32 · lg 36 · xl 40

icon-*

the matching rung, and always a label

Input size + Button size

a pair asks for the same rung

Don't

<Button size="lg" className="h-10 px-4">

Do

<Button size="xl">

Both produce the same 40px button today. Fourteen call sites had each written the left one, because the ladder stopped at 36px and every one of them wanted 40 — and each was then free to drift to h-11, or to forget the padding. Fourteen identical local fixes are a missing rung, so xl was added to the component, once, where the next person will find it.

ButtonLink

navigates — a real anchor

ButtonGroup

a cluster of related actions

CopyButton

composition — DNS values

postbox-verification=a1b2c3d4

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

c/e#

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

@example.test

Do

@example.test

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

Connected

Do

Connected

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

DomainVerifyInbox

Do

  1. Domain
  2. Verify
  3. 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.