Skip to content

Wrong UI must not typecheck.

The type system is the first reviewer. A nonsensical combination of props is a compile error, not a runtime surprise.

Materia UI is restrictive on purpose: the right thing is easier to build than the wrong one. Wherever a gate can enforce a principle, it does, and the gate's message is quoted beside it 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, with no duplicate to drift. The same goes for the engines underneath. Base UI arrives only inside system components, and icons only through Icon.

Do not import Base UI primitives directly (Radix is not used at all). Consume them through @instruments/materia-ui components (add the primitive to @instruments/materia-ui if it is missing).

Enforced by

lint/no-raw-base-ui.mjs

Apps and packages consume Base UI primitives only through @instruments/materia-ui components, never by importing @base-ui/react directly.

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

Enforced by

lint/no-raw-lucide.mjs

Apps import Lucide glyphs from @instruments/materia-ui/icons and render them through Icon, never importing lucide-react directly.

32 components build on Button rather than restyling their own.

02

Types are the first reviewer

Props are closed unions, not 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. 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

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 flush beside an Input without anyone measuring. Variants, alignment and direction are shared enums: learn them once and they work 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/oxlint.config.ts

04

No raw elements

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

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 wasn't asked for. The space it sits in belongs to whatever composes it. A Stack sets the gap, a Container the measure, a Section the rhythm, and a Box is the zero-opinion wrapper. Stories follow the same rule, with framing in a decorator rather than the component, so a piece drops into any layout without fighting its own padding.

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

06

A fix rolls up

Components are made of components, and layers import in one direction only. Fix the focus ring on a primitive and every control composed from it is fixed in the same commit. The flat import shims give consumers a stable path, so internals can be reorganised without breaking them.

Enforced by

packages/ui/scripts/layer-matrix.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 data●tokens may import from tokenstokens may not import from utilstokens may not import from hookstokens may not import from componentstokens may not import from compositions
utils●utils may import from data●utils may import from tokens●utils may import from utilsutils may not import from hooksutils may not import from componentsutils may not import from compositions
hooks●hooks may import from data●hooks may import from tokens●hooks may import from utils●hooks may import from hookshooks may not import from componentshooks may not import from compositions
components●components may import from data●components may import from tokens●components may import from utils●components may import from hooks●components may import from componentscomponents may not import from compositions
compositions●compositions may import from data●compositions may import from tokens●compositions may import from utils●compositions may import from hooks●compositions may import from components●compositions may import from compositions
Import from the flat shim @instruments/materia-ui/<components|compositions>/<name>, not a deep internal path (@instruments/materia-ui/<components|compositions>/<name>/<part>). The shim re-exports every public part.

Enforced by

lint/materia-policy.mjs

Apps and packages import each component or composition from its flat shim @instruments/materia-ui/components/<name> or @instruments/materia-ui/compositions/<name>, never from a deep internal part path.

07

Compositions must earn the tier

The package has two tiers: components, and compositions above them. A composition is admitted only when more than one app is expected to render it, such as a product card or a variant selector. Anything a single app needs lives in that app.

34 compositions currently earn it.

08

Anatomy is data

A compound component ships a typed anatomy: every part, its slot and its purpose. Each part is addressable in the DOM through its data-slot, Storybook renders the tree, and the anatomy sections on this site are generated from it.

31 anatomy files describe the compound components.

09

One Base story first

A component's first story is Base: one instance wired straight to the controls, with no scenery and no props it doesn't need. If it can't stand alone there, it doesn't ship. Around Base sit accessibility checks, pseudo-state stories, play-function interaction tests and pixel-level visual regression against committed snapshots.

171 story files · 63 interaction-tested · 2 addons.

10

Every export is documented

Every export carries working JSDoc: @example, @category, parameter docs and cross-references. It shows as hover help in the IDE, compiles into the agent docs coding agents read, and generates the manifest these pages render from. When documentation drifts from the code, the lint fails.

11

Best practice travels with the code

Each component carries its own @bestPractice lines, one do or don't per line. They travel with the code into hover help, the agent docs and this site, so the advice changes in the same diff as the behaviour it describes.

165 of 165 documented units carry @bestPractice notes.

12

Strict oxlint

The package's source is linted on the oxc toolchain: oxlint with an extended React preset and the system's own doctrine plugins, oxfmt for formatting. The import bans it exports to consuming apps are oxlint config fragments on the same toolchain. Rules are errors, not warnings. The few switched off are documented, and the doctrine gates, including bundle and motion discipline, run on every lint.

Enforced by

packages/ui/oxlint.config.ts

Requirements

In exchange for the guarantees above, a consuming app accepts a fixed environment.

One stylesheet

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

@instruments/materia-ui/styles
Wrap in ReducedMotionProvider

Mount ReducedMotionProvider at the app root. The m.* LazyMotion primitives resolve their motion features through it, so animation collapses under a reduced-motion preference.

React ≥ 19.2

A hard peer requirement. The components are built against React 19.2 and Server Components, with no shim for older majors.

react@^19.2.0
Tailwind v4

The token layer is a Tailwind v4 theme and the components' classes assume v4 utilities. There is no v3 build.

ESM only

Pure ES modules with no side effects, so bundlers tree-shake unused components. 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 interactive parts opt in.

Brand is the one override

Set the four --color-brand* variables after the stylesheet import to use your own brand. There is no other theming API, no runtime theme prop and no provider. Light and dark are the only modes.