Skip to content

Component Library Design: Primitives → Domain

Layer a UI system from primitives to patterns to domain components, and decide what belongs in a shared package versus app code.

5 min read
Component Library
Design System
React
Primitives
Frontend Architecture

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

LayerQuestion it answersExamples
PrimitiveHow does this control behave and look at rest?Button, Input, Dialog, Checkbox
PatternHow do common tasks compose primitives?ConfirmDialog, FormField, DataTable shell
DomainWhat 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

  1. Primitives change slowly: visual tokens and a11y first; API breakage is rare and changelogged.
  2. Domain moves with the product: prefer app modules; extract to a shared package only when a second app needs the same domain UI.
  3. Cross-app domain (rare) gets @acme/invoices-ui, not a bloated @acme/ui.
  4. 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.