Honest state boundaries do not make a product UI. You still need components -
and most "design systems" fail by collapsing three layers into one folder named
ui/.
I design libraries in three altitudes: primitives, patterns, and
domain. The craft is knowing which altitude a component belongs on, and
whether it ships in @acme/ui or stays in the app.
Three altitudes
| Layer | Question it answers | Examples |
|---|---|---|
| Primitive | How does this control behave and look at rest? | Button, Input, Dialog, Checkbox |
| Pattern | How do common tasks compose primitives? | ConfirmDialog, FormField, DataTable shell |
| Domain | What does this mean in our product? | InvoiceStatusBadge, ProjectPicker |
Primitives are reusable across products. Domain components encode product vocabulary. Patterns sit in between, reusable choreography without business nouns.
tsx// packages/ui/src/button.tsx, illustrative primitive import type { ButtonHTMLAttributes } from 'react'; type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'secondary' | 'ghost'; size?: 'sm' | 'md'; }; export function Button({ variant = 'primary', size = 'md', type = 'button', ...props }: ButtonProps) { return ( <button type={type} data-variant={variant} data-size={size} className="acme-btn" {...props} /> ); }
No Invoice, no approve, no fetch. If your primitive imports a query hook, it
is already domain, and your package boundary is lying.
What belongs in the design-system package
Ship in @acme/ui (or equivalent) when:
- Behavior is product-agnostic (focus trap, keyboard, disabled, loading)
- Visual API is token-driven (last quarter's CSS variables / themes)
- Consumers need stable imports across apps in the monorepo
- Breaking changes should be versioned, not drive-by refactors
ts// packages/ui/src/index.ts, illustrative public surface export { Button } from './button'; export { TextField } from './text-field'; export { Dialog } from './dialog'; export { FormField } from './form-field'; // Do NOT export InvoiceStatusBadge from here
Keep the public surface small. Every export is a compatibility promise.
What stays in app code
Leave in apps/web/src/features/... when:
- The component names a business entity
- It calls APIs, Query keys, or permission checks
- Copy, empty states, or workflows are product-specific
- Only one app will ever need it (premature extraction is a tax)
tsx// apps/web/src/features/invoices/InvoiceStatusBadge.tsx, domain import { Badge } from '@acme/ui'; const LABELS = { draft: 'Draft', sent: 'Sent', paid: 'Paid', } as const; export function InvoiceStatusBadge({ status, }: { status: keyof typeof LABELS; }) { return <Badge data-status={status}>{LABELS[status]}</Badge>; }
Badge is primitive/pattern. InvoiceStatusBadge is domain. Promoting the
badge into @acme/ui couples every consumer to invoice vocabulary, usually the
wrong trade.
Patterns: the easy layer to abuse
FormField (label + control + error) is a pattern. SmartInvoiceForm with
validation schema + mutation is domain.
Failure mode: a patterns/ folder that becomes a junk drawer of half-domain
widgets with props like entity="invoice". If you need an entity enum to
render, you crossed the line, move it to a feature module.
tsx// packages/ui/src/form-field.tsx, illustrative pattern import type { ReactNode } from 'react'; export function FormField({ id, label, error, children, }: { id: string; label: string; error?: string; children: ReactNode; }) { const errorId = error ? `${id}-error` : undefined; return ( <div className="acme-field"> <label htmlFor={id}>{label}</label> {/* clone/slot: wire aria-describedby in real impl */} {children} {error ? ( <p id={errorId} role="alert" > {error} </p> ) : null} </div> ); }
Patterns may know accessibility choreography. They should not know your API error codes.
Ownership and versioning rules
- Primitives change slowly: visual tokens and a11y first; API breakage is rare and changelogged.
- Domain moves with the product: prefer app modules; extract to a shared package only when a second app needs the same domain UI.
- Cross-app domain (rare) gets
@acme/invoices-ui, not a bloated@acme/ui. - No circular imports from ui → app. Domain may depend on ui; never the reverse.
Decision criteria in PR review
- Does this component mention a product noun? → domain / feature.
- Can I reuse it in a second product without renaming? → primitive or pattern.
- Does it fetch? → not a primitive.
- Does it hard-code hex colors? → fix tokens first (May), don't paper over with another variant prop.
Failure modes I keep deleting
"Shared" folder that is really app-specific. A packages/ui full of
BillingBanner and SeatLimitModal is a domain package with a lying name.
Rename or move, do not pretend reuse.
Variant explosion on primitives. Twenty Button variants that encode
product workflows (approveInvoice, dangerZoneBilling) belong as domain
wrappers, not as a combinatorial API on the primitive.
Storybook-only components. If a primitive is documented but unused in apps, it is speculative inventory. Prefer extracting after the second real call site.
Theming via prop soup. Passing backgroundColor="#3b82f6" through five
layers means tokens never became the contract. Fix the token layer; stop adding
escape props.
Continuity
Client/server boundaries keep remote data out of UI stores. Primitives keep product nouns out of the primitive package. Accessible UI libraries cover when to buy primitives instead of building them, judged on a11y guarantees and ownership cost.
Takeaway
Primitives are behavior and tokens; domain is product vocabulary. A library that mixes both becomes a compatibility trap with a nice Storybook.