Skip to content

Wrong UI must not typecheck.

The system's invariant, stated in docs/ui.md: the type system is the first reviewer. A nonsensical combination of props is a compile error, not a runtime surprise.

MateriaUI is deliberately restrictive — it is easier to build the right thing than the wrong one. Eleven principles govern the system, and none of them relies on discipline: wherever a principle can be enforced by a gate, it is, and the gate's own message is quoted beside it exactly as the build prints it.

01

One of everything

Anything that looks like a button is the Button. One Input, one Dialog, one Menu — behaviour and appearance stay coupled because there is exactly one implementation to render, and no duplicate drifting out of sync. Even the engines underneath obey the rule: Radix arrives only inside system components, icons only through Icon.

Do not import Radix primitives directly. Consume them through @desk/ui components (add the primitive to @desk/ui if it is missing).

Enforced by

packages/ui/lint/biome.json

packages/ui/lint/biome-strict.json

Do not import lucide-react directly. Import glyphs from @desk/ui/icons (and render via the Icon component) so the icon dependency stays behind the design-system boundary.

Enforced by

packages/ui/lint/biome-strict.json

21 components build on Button rather than restyling their own.

02

Types are the first reviewer

Wrong UI must not typecheck. Props are closed unions rather than open strings, incompatible combinations are compile errors, and a discriminated prop retypes its siblings — switch a toggle group to multiple and onValueChange demands a string[] before the code will build. The public surface itself is under type control: every export's resolved signature is hashed, and unreviewed drift fails the gate.

Enforced by

packages/ui/scripts/generate-api-report.mjs

03

One grammar of props

Every component speaks the same prop vocabulary. size draws from one eight-step scale — 2xs through 3xl — and every control at a given size resolves its height from the same token map, so a Button sits pixel-flush beside an Input without anyone measuring. Semantic variants, alignment, direction: shared enums, learned once, guessable everywhere.

Enforced by

packages/ui/lint/scan-classes.mjs

Class strings carry no raw hex/oklch/oklab colours and no arbitrary Tailwind values — only design tokens and theme utilities.

packages/ui/scripts/check-avoidable-arbitraries.mjs

Spacing utilities use the standard token scale, never an arbitrary -[Npx]/-[Nrem] value that lands on a clean token step.

04

No raw elements

Application markup renders only system components — a bare div is a lint error. Semantics come from the as prop's authored, closed menu, and the menu refuses what the system already owns: as="button" is a type error, because the correct answer is Button. The one escape hatch left open, className on a primitive, is tokens-policed.

Enforced by

packages/ui/lint/oxlint/no-naked-element.mjs

Application markup renders only design-system components — every raw lowercase HTML host element is lint-banned, leaving the closed polymorphic `as` vocabulary and a tokens-policed className as the only routes to the metal.

05

A component owns its job, not its space

A component renders its own job and nothing around it — no baked margin, no width cap, no wrapper card or heading it was not asked for. The space a component sits in belongs to whatever composes it: a Stack sets the gap, a Container the measure, a Section the rhythm, a Box the zero-opinion wrapper. Even the demos obey it — a story's framing lives in a decorator, never in the component — so the same piece drops into any layout without fighting the padding it brought with it.

16 layout primitives carry the spacing, so components never bake their own.

06

A fix rolls up

Components are made of components, and the layers import one way only. Correct the focus ring on a primitive and every control composed from it is corrected in the same commit — nothing has to be found and patched twice. The flat import shims keep the roll-up safe: consumers address a stable seam, so the internals behind it can be reorganised freely.

Enforced by

packages/ui/scripts/check-layers.mjs

Each src layer may import only from the layers at or below it (data ← tokens ← utils ← hooks ← components ← compositions); tokens additionally stay free of external packages, except statement-level type-only imports which erase at build time.

Rows import from columns. A filled cell is a permitted import; the empty upper triangle is what the gate forbids.
Layer ↓ / may import →datatokensutilshookscomponentscompositions
datadata may not import from datadata may not import from tokensdata may not import from utilsdata may not import from hooksdata may not import from componentsdata may not import from compositions
tokenstokens may not import from datatokens may import from tokenstokens may not import from utilstokens may not import from hookstokens may not import from componentstokens may not import from compositions
utilsutils may import from datautils may import from tokensutils may import from utilsutils may not import from hooksutils may not import from componentsutils may not import from compositions
hookshooks may import from datahooks may import from tokenshooks may import from utilshooks may import from hookshooks may not import from componentshooks may not import from compositions
componentscomponents may import from datacomponents may import from tokenscomponents may import from utilscomponents may import from hookscomponents may import from componentscomponents may not import from compositions
compositionscompositions may import from datacompositions may import from tokenscompositions may import from utilscompositions may import from hookscompositions may import from componentscompositions may import from compositions
Import from the flat shim @desk/ui/components/<name>, not a deep internal path (@desk/ui/components/<name>/<part>). The shim re-exports every public part.

Enforced by

scripts/check-deep-imports.mjs

packages/ui/lint/biome.json

07

Compositions must earn the tier

The package has two tiers: the component vocabulary below, compositions above. A composition is admitted only when more than one app is expected to render it — a product card, a variant selector. Anything a single app needs lives in that app. The tier is a contract about sharing, not a place to park screens.

6 compositions currently earn it.

08

Anatomy is data

A compound component ships its skeleton as a typed anatomy: every part, its slot, its purpose. Each part is addressable in the DOM through its data-slot, Storybook renders the tree, and the documentation on this site is generated from it — what a Dialog is made of is recorded, never guessed.

31 anatomy files describe the compound components.

09

One Base story — then every trick in the book

A component's first story is Base: a single instance wired straight to the controls, no scenery, no props it does not need. If it cannot stand on its own there, it does not ship. Around that base the full Storybook arsenal is in play — accessibility checks, pseudo-state stories, play-function interaction tests, and pixel-level visual regression against committed snapshots.

163 story files · 59 interaction-tested · 2 addons in play.

10

Documented to the hover

Every export carries working JSDoc — @example, @category, parameter docs, cross-references. The comments are load-bearing: they surface as hover help in the IDE, compile into the agent docs that coding agents read, and generate the manifest these pages are rendered from. When documentation drifts from code, the lint fails.

11

Best practice travels with the code

Guidance is not a wiki that rots. Each component carries its own @bestPractice lines — one do or don't per line — and they ride with the code into hover help, the agent docs and this site. The advice changes in the same diff as the behaviour it describes.

159 of 159 documented units carry @bestPractice notes.

12

Oxlint, and strict

The package's own source is linted on the oxc toolchain — oxlint with an extended React preset and the system's custom doctrine plugins, oxfmt for formatting. Biome exists here only as a preset exported to consuming apps. Rules are errors, not warnings; the handful switched off are off for documented, load-bearing reasons; and the doctrine gates — bundle and motion discipline included — run on every lint.

Enforced by

packages/ui/scripts/check-motion.mjs

Components render the lightweight m.* LazyMotion primitives, never the full motion.* namespace that would pull the whole animation engine into every consumer's bundle.

The coupling contract

Restriction runs both ways. In exchange for the guarantees above, a consuming app accepts a fixed environment — there are no knobs to soften it.

One stylesheet

Import the package's single CSS entry once, at the app root. It carries the whole token layer — every colour, size, radius and motion value the components read.

@desk/ui/styles
Wrap in ReducedMotionProvider

Every consuming app mounts ReducedMotionProvider at its root. The m.* LazyMotion primitives resolve their motion features through it, so animation collapses correctly under a reduced-motion preference.

React ≥ 19.2

A hard peer lock — the components are built against React 19.2 and its Server Components model. There is no compatibility shim for older majors.

react@^19.2.0
Tailwind v4

The token layer is a Tailwind v4 theme. The classes the components emit assume v4 utility generation; there is no v3 build.

ESM only

The package is pure ES modules with no side effects, so bundlers tree-shake unused components away. There is no CommonJS entry.

"type": "module"
Next ≥ 15

The RSC-safe primitives target the App Router in Next 15 and later. Server Components render without a client boundary; only the interactive parts opt in.

No theming API

There is no runtime theme prop, no provider to reconfigure colours, no override hook. The tokens are the contract — light and dark are the two modes, and that is the whole surface.