Primitives Pattern
How to ship a one-package primitives catalog so every screen looks like the same product.
Primitives Pattern
How to ship a one-package primitives catalog so every screen looks like the same product.
TL;DR (human)
One UI package owns every interactive primitive. Components in screens import from that package. Native HTML elements (\<button\>, \<input\>, \<select\>, etc.) are lint-banned in shipped surfaces. Primitives are styled with design tokens; brand swap reaches them automatically.
For agents
The catalog
Minimum viable primitives catalog:
| Primitive | Replaces native | Variants |
|---|---|---|
Button | \<button\>, \<a href\> (action) | primary / secondary / ghost / danger; sm / md / lg |
IconButton | \<button\> with icon-only content | size + variant |
Link | \<a href\> (navigation) | primary / muted |
Input | `<input type="text | |
Textarea | \<textarea\> | auto-resize / fixed |
Select | \<select\> | single / multi (using Radix or equivalent) |
Checkbox | <input type="checkbox"> | with-label / indeterminate |
Radio, RadioGroup | <input type="radio"> | with-label |
Switch | <input type="checkbox"> (toggle role) | |
Dialog | \<dialog\> | modal / drawer |
Tooltip | title attr | |
Tabs | role="tablist" boilerplate | |
Table | \<table\> | sortable / paginated |
Badge | \<span\> with class | status colors |
Avatar | \<img\> | with-initials / with-presence |
EmptyState | (none — new primitive) | with-icon / with-illustration |
Skeleton | (loading shimmer) | text / block / row |
Toast | (system notification) | success / error / info |
Add per project: KPI, Card, Stat, Stepper, BreadcrumbBar, etc.
Why a primitive replaces a native element
| Concern | Native | Primitive |
|---|---|---|
| Styling | Browser default; varies | Token-driven; consistent |
| A11y attributes | Manually applied per use | Built-in; consistent |
| Keyboard handling | Browser default; subtle bugs | Tested + consistent |
| Focus ring | Browser default (sometimes invisible) | Token-driven; always visible |
| Disabled state | disabled only | disabled + visual + aria-disabled |
| Loading state | Manual hand-rolling | Built-in loading prop on Button |
| Form integration | Native | Compatible with form library |
A primitive enshrines the right pattern once; every consumer benefits.
Built on something
Build on top of a headless library (Radix Primitives, React Aria, Headless UI) for a11y semantics. Wrap with your tokens + variants. Do not roll keyboard handling and focus management yourself; the headless libraries have spent thousands of hours on edge cases.
Your job:
- Provide the visual layer (tokens, variants).
- Provide consistent API (prop names, callback signatures) across primitives.
- Provide your project's idioms (loading prop, intl prop).
API consistency
All primitives share API conventions:
interface BasePrimitiveProps {
// identification
id?: string;
className?: string; // composable; never wholesale-replaces internal styles
// a11y
"aria-label"?: string;
"aria-labelledby"?: string;
"aria-describedby"?: string;
// state
disabled?: boolean;
loading?: boolean; // where applicable
}Variants are declared with a small variant utility (e.g. cva from class-variance-authority) so the same variant / size prop semantics apply everywhere.
File / package shape
packages/ui/
├── src/
│ ├── button/
│ │ ├── button.tsx // ≤ 200 lines
│ │ ├── button.stories.tsx // visual catalog
│ │ ├── button.test.tsx
│ │ └── index.ts
│ ├── input/
│ ├── select/
│ ├── dialog/
│ ├── tokens.css // token definitions (or imported from a sibling pkg)
│ └── index.ts // re-export all primitives
└── package.jsonPer-primitive subdir lets you split the implementation as it grows; the index file is one barrel for consumers.
Stories / visual catalog
Every primitive has a stories file. Two purposes:
- Visual regression: snapshots run in CI; token drift surfaces.
- Documentation: agents reading the primitive's API see all variants in one place.
Stories use Ladle / Storybook / your project's tool. Same toolchain as the rest of the repo.
The gate
Lint rule:
// no native html in shipped surfaces
"no-restricted-syntax": [
"error",
{
selector: "JSXOpeningElement[name.name=/^(button|input|select|textarea|dialog|form|table|a)$/]",
message: "Use the shared primitive from @your/ui instead of native HTML.",
},
],Per file overrides for framework-mandated places (Next.js layouts, MDX content, raw HTML editors).
Escape hatch: // allow-native: \<reason\>. Counted by a gate that fails on growth.
Migration path
Brownfield: large existing app, lots of native elements.
- Ship primitives.
- Generate baseline of native-html offenders.
- Gate to shrink-only.
- Codemod where possible (
<button onClick={...}>X\</button\>→<Button onClick={...}>X\</Button\>). - Manual sweep for tricky cases (forms;
\<a\>that mixes nav with action).
Common failure modes
- Primitive that wraps a native element 1:1 with no value-add. Just use the native. → Primitive must add tokens + a11y + consistent API.
- Primitive that does too much. A
\<Button\>with 30 props. → Split into composable primitives (Button,Spinner,Iconseparately). - Primitive with a
styleprop accepted unchecked. Defeats tokens. →classNameprop only; styles internal. - One-off variant added inline in a screen (
<Button className="bg-red-500">). → Add the variant to the primitive; do not override per-use. - No stories file. Agents don't know the variants exist; reinvent. → Mandatory per primitive.
See also
universal.md— Rule 2.design-tokens-pattern.md— what primitives style with.a11y-checklist.md— primitive a11y contract.