Guides
Publishing an npm Library for MotifJS
Preparing an npm package with MotifJS components, services or wrappers; the package format, build, types, CSS, the prop contract, testing and pre-publish checks.
If you are writing a UI library, a wrapper or a set of services and want others to use it through npm install, this section is for you. The package must attach to the same @motifx/core instance the application uses, carry its JSX already compiled, and impose nothing on the consumer's build setup. The setup below makes that happen.
The package's boundary
There are two kinds of packages, and the difference in setup is small:
| Package | Content | Is the compiler needed? |
|---|---|---|
| A package without JSX | .ts components that do not use JSX (super('canvas'), controls.add, bindings.*) |
No, if the root element is declared with static elementTag or super(...) and no decorators are used; tsc or any bundler is enough. If the root element is to be produced from the generic, the compiler plugin is needed. |
| A JSX library (a UI kit) | .tsx components |
Yes; the package is compiled with @motifx/compiler before it is published. |
In both cases @motifx/core is a peer dependency: the package does not bundle it, it uses the copy the consumer installs. Two copies mean two reactivity engines, and that error is silent (see The single-copy rule).
package.json
{
"name": "@acme/motif-ui",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./style.css": "./dist/style.css",
"./package.json": "./package.json"
},
"files": ["dist", "README.md"],
"scripts": {
"dev": "vite",
"build": "vite build && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"prepublishOnly": "npm run build"
},
"peerDependencies": {
"@motifx/core": "^1.0.0"
},
"devDependencies": {
"@motifx/core": "^1.0.0",
"@motifx/compiler": "^1.0.0",
"jsdom": "^26.0.0",
"typescript": "^5.4.0",
"vite": "^5.4.0",
"vitest": "^3.0.0"
},
"keywords": ["motifjs", "motifx"],
"publishConfig": { "access": "public" }
}
- The same
@motifx/coremajor inpeerDependenciesanddevDependencies: one is a requirement for the consumer, the other is for your own build and tests. @motifx/compileris only indevDependencies; the consumer does not have to install it, because the package carries compiled code.sideEffects: falseturns on tree-shaking; in that case do not have a module that does work when imported (global CSS injection, a global registration). If you need something like that, export it as an explicit function (registerX(app)).- A third-party library you wrap (such as
chart.js) becomes a peer dependency too; add it to theexternallist as well.
Build
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import compiler from '@motifx/compiler';
export default defineConfig({
plugins: [compiler()],
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es', 'cjs'],
fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
},
rollupOptions: {
external: ['@motifx/core'],
},
sourcemap: true,
minify: false,
target: 'es2020',
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
},
});
/// <reference types="vitest/config" />
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import compiler from '@motifx/compiler';
export default defineConfig({
plugins: [compiler()],
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es', 'cjs'],
fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
},
rollupOptions: {
external: ['@motifx/core'],
},
sourcemap: true,
minify: false,
target: 'es2020',
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
},
});
{
"compilerOptions": {
"target": "ES2021",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"useDefineForClassFields": true,
"noImplicitOverride": true,
"declaration": true,
"declarationMap": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"]
},
"include": ["./src/**/*", "./vite.config.ts"]
}
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "./dist/types",
"rootDir": "./src"
},
"include": ["./src/**/*"],
"exclude": ["./src/**/*.test.ts", "./src/**/*.test.tsx"]
}
What happens:
vite buildruns in library mode;@motifx/compilerbrings the JSX in every.tsxfile down tobindings.*calls. The output is plain JavaScript with no JSX left; the@motifx/coreimports (motifComponent,motifCompiled…) areexternal, so they stay outside the package and attach to the consumer's copy.tsc -p tsconfig.build.jsonproduces only.d.ts; the compiler does not touch the types. Thestatic elementTagproduced from theComponent<HTMLDivElement>generic exists in the compiled JS; it does not appear on the type side, and it does not need to.- Compiled code depends on a compiler contract. The core recognizes the contracts it supports; for code compiled with an unsupported contract, the consumer sees an
MJX121warning in development mode. Give thepeerDependenciesrange according to the major version of the core you compiled the package with (^1.0.0); when the core's major version goes up, recompile and republish the package. noImplicitOverride: if your component classes accidentally shadow a member ofComponentBase(dispose,controls,props,element…), you get a compile error.
In a wrapper that does not use JSX the jsx lines are unnecessary. plugins: [compiler()] is still needed: it is what produces the element type from the generic in .ts files and transforms decorators. If you write the element yourself with static elementTag and use no decorators, you can drop it too; the rest is the same.
Entry point and exports
export { Button } from './Button';
export { Dialog } from './Dialog';
export type { ButtonProps } from './Button';
export { registerUiServices } from './services';
export { Button } from './Button';
export { Dialog } from './Dialog';
export { registerUiServices } from './services';
- Export the components and prop types by name; a
default exportonly makes sense for single components meant forLazy/lazy route loading. - You can offer heavy components (an editor, a chart, a map) from separate subpaths:
exports["./editor"]+ abuild.lib.entryobject. The consumer loads only what is needed withLazy({ caller: () => import('@acme/motif-ui/editor') })(see Lazy).
The prop contract
The package's components will be used in other people's code; define a prop surface that follows the compiler's rules:
import { Component, read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export class Button extends Component<HTMLButtonElement, ButtonProps> {
override onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(this.props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(this.props.disabled) ?? false });
this.motif.on("click", (_button, e: MouseEvent) => this.props.onPress?.(e));
}
override view() {
return <>{() => read(this.props.label)}</>;
}
}
import { Component, ComponentBase, motifComponent, read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export class Button extends Component<HTMLButtonElement, ButtonProps> {
static override elementTag = "button";
override onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(this.props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(this.props.disabled) ?? false });
this.motif.on("click", (_button, e: MouseEvent) => this.props.onPress?.(e));
}
override view() {
return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => read(this.props.label)));
}
}
import { read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export function Button(props: ButtonProps) {
return (
<button
type="button"
class={() => `mf-button mf-button--${read(props.tone) ?? "default"}`}
disabled={() => read(props.disabled) ?? false}
onclick={(e) => props.onPress?.(e)}
>
{() => read(props.label)}
</button>
);
}
import { ComponentBase, motifComponent, read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export function Button(props: ButtonProps) {
return motifComponent("button", (button: ComponentBase) => {
button.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
button.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
button.motif.on("click", (_button: ComponentBase, e: MouseEvent) => props.onPress?.(e));
button.bindings.text(() => read(props.label));
});
}
import { motifOptions, read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export const Button = (props: ButtonProps) => motifOptions({
el: "button",
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
this.motif.on("click", (_button, e: MouseEvent) => props.onPress?.(e));
},
view() {
return <>{() => read(props.label)}</>;
},
});
import { ComponentBase, motifComponent, motifOptions, read, type Bind } from "@motifx/core";
export interface ButtonProps {
label: Bind<string>;
tone?: Bind<"default" | "accent" | "danger">;
disabled?: Bind<boolean>;
onPress?: (e: MouseEvent) => void;
}
export const Button = (props: ButtonProps) => motifOptions({
el: "button",
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
this.motif.on("click", (_button, e: MouseEvent) => props.onPress?.(e));
},
view() {
return motifComponent("text", (node: ComponentBase) => node.bindings.text(() => read(props.label)));
},
});
import { Component, read } from "@motifx/core";
export class Button extends Component {
static elementTag = "button";
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(this.props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(this.props.disabled) ?? false });
this.motif.on("click", (_button, e) => this.props.onPress?.(e));
}
view() {
return <>{() => read(this.props.label)}</>;
}
}
import { Component, motifComponent, read } from "@motifx/core";
export class Button extends Component {
static elementTag = "button";
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(this.props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(this.props.disabled) ?? false });
this.motif.on("click", (_button, e) => this.props.onPress?.(e));
}
view() {
return motifComponent("text", (node) => node.bindings.text(() => read(this.props.label)));
}
}
import { read } from "@motifx/core";
export function Button(props) {
return (
<button
type="button"
class={() => `mf-button mf-button--${read(props.tone) ?? "default"}`}
disabled={() => read(props.disabled) ?? false}
onclick={(e) => props.onPress?.(e)}
>
{() => read(props.label)}
</button>
);
}
import { motifComponent, read } from "@motifx/core";
export function Button(props) {
return motifComponent("button", (button) => {
button.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
button.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
button.motif.on("click", (_button, e) => props.onPress?.(e));
button.bindings.text(() => read(props.label));
});
}
import { motifOptions, read } from "@motifx/core";
export const Button = (props) => motifOptions({
el: "button",
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
this.motif.on("click", (_button, e) => props.onPress?.(e));
},
view() {
return <>{() => read(props.label)}</>;
},
});
import { motifComponent, motifOptions, read } from "@motifx/core";
export const Button = (props) => motifOptions({
el: "button",
onConfigured() {
this.class.add("mf-button", () => `mf-button--${read(props.tone) ?? "default"}`);
this.attr.add({ type: "button", disabled: () => read(props.disabled) ?? false });
this.motif.on("click", (_button, e) => props.onPress?.(e));
},
view() {
return motifComponent("text", (node) => node.bindings.text(() => read(props.label)));
},
});
The consumer uses the component in their own form:
<Button label="New note" tone="accent" onPress={() => this.create()} />
<Button label="New note" tone="accent" onPress={() => create()} />
const newNote = new Button({ label: "New note", tone: "accent", onPress: () => this.create() });
const newNote = motifComponent(Button, { label: "New note", tone: "accent", onPress: () => this.create() });
const newNote = motifComponent(Button, { label: "New note", tone: "accent", onPress: () => create() });
<Button label="New note" tone="accent" onPress={() => this.create()} />
<Button label="New note" tone="accent" onPress={() => create()} />
const newNote = new Button({ label: "New note", tone: "accent", onPress: () => this.create() });
const newNote = motifComponent(Button, { label: "New note", tone: "accent", onPress: () => this.create() });
const newNote = motifComponent(Button, { label: "New note", tone: "accent", onPress: () => create() });
In the function component, create is a local function inside the layout function.
In a class, view() returns the content, not the root. The root element comes from the generic (Component<HTMLButtonElement>), from static elementTag or from a super("button") call; the root's class, attributes and events are attached in onConfigured. Writing a <button> again inside view() produces two nested elements.
In Options, view() returns the content, not the root. The root element is the el field; the root's class, attributes and events are attached in onConfigured. Writing a <button> again inside view() produces two nested elements.
In a function component the returned element is the root; classes, attributes and events are written directly on it.
The rules:
- Every prop that can change is a
Bind<T>and is read withread(). The consumer can writelabel="Save"orlabel={() => state.text}; the compiler already wraps a ternary in an attribute in a getter (see Ternaries in attributes). If the prop is read withoutread(), the getter form does not work. - Common attributes fall through to the root.
class,style,id,tabindex,role,aria-*anddata-*are applied automatically from the component tag to the root element; do not define them as props and copy them by hand (see Attribute fallthrough). The root's ownclassis merged with the consumer's. - Do not use reserved names:
options,ref,key,transition,childs,initializeComponentand the names of the lifecycle hooks (onBuilt,onMounted…) go to the framework (see Careful with prop names). - Do not give a callback a name that clashes with a DOM event name. A prop such as
onChange,onSelect,onResize,onReset,onCopyoronContextMenuon a component tag is attached to the root element as a DOM listener and never reaches the props; the compiler warns withMJX002. Names such asonPress,onValueChangeandonCommitare safe. - Assume the consumer will reach the component instance through
ref; expose the behaviour you want to offer as a method (dialog.open()) and keep the inner fields closed.
Child content arrives through childs; in a class whose root element is declared with the generic or static elementTag and that writes no view(), the <Card>…</Card> form works directly. In a class that builds its root with super("div", props), you place the children yourself (see Child components).
Child content arrives through props.childs and is placed inside the returned root (see Child components).
Child content arrives through childs; in an Options component that writes no view(), the <Card>…</Card> form works directly, and the children are added to the el element. An Options component that writes view() places them itself with this.childs (see Child components).
Styles and CSS
Ship the package's CSS as a single file, and let the consumer import it deliberately:
// consumer
import '@acme/motif-ui/style.css';
// consumer
import '@acme/motif-ui/style.css';
- In library mode, CSS imported from
src/index.tsis written asdist/style.cssin Vite 5 (in Vite 6, give the name withbuild.lib.cssFileName: 'style');exports["./style.css"]makes it addressable. Importing it from the entry does not conflict withsideEffects: false: bundlers treat CSS files as having side effects. - Put the rules inside an
@layer(@layer mf.base, mf.components;). Unlayered rules override every layer regardless of specificity, so a one-line override by the consumer always wins; give theme values through prefixed CSS variables such as--mf-*. - A
<style>tag inside a component (see Inline style) is useful for small, self-contained components, but it produces a<style>node in every instance and is not scoped; at library scale, prefer a file. - Document the class names for transitions: a consumer who writes
transition="mf-fade"expects themf-fade-enter-from/active/torules from you (see The transition prop).
Services
If the package carries a service (a theme, a notification queue, a language), mark it with @Injectable; the class is marked the moment its module loads and is registered when the application is build()-ed. A class whose module loads after build() is registered on its first resolution (see Automatic registration with decorators). The compiler plugin compiles decorators in .ts files too; in a wrapper without the plugin, TypeScript compiles the standard decorator itself.
If the consumer needs to choose the lifetime, an explicit registration function is clearer:
import { ServiceCollection } from '@motifx/core';
import { ThemeService } from './ThemeService';
export function registerUiServices(services: ServiceCollection) {
services.addSingleton(ThemeService, ThemeService);
}
import { ThemeService } from './ThemeService';
export function registerUiServices(services) {
services.addSingleton(ThemeService, ThemeService);
}
// consumer
const builder = Application.CreateBuilder();
registerUiServices(builder.services);
const app = builder.build();
// consumer
const builder = Application.CreateBuilder();
registerUiServices(builder.services);
const app = builder.build();
An explicit registration overrides the decorator, so giving both is safe.
Testing and development
vitest+jsdomwork with the same plugin as invite.config.ts; no extra setting is needed for JSX in tests. Add things todocumentand test theonMountedbehaviour as well.- Write a round-trip test for every component: build it, add it to the DOM,
dispose(), and check that the number of live components went back to where it started (see Prove the cleanup). A leak only shows up this way. - For development, put an
index.htmlandsrc/showcase/in the package root, and look at the components in a real browser withvite. The showcase stays outsideexports, so it does not go into the package. motif-lint(it comes with@motifx/compiler) checks JSX against the prop types; add"lint:jsx": "motif-lint"topackage.json(see motif-lint).
Pre-publish checks
npm run build→ weredist/index.js,dist/index.cjs,dist/types/index.d.tsanddist/style.csscreated?- Is there no
jsx(/createElement(indist/index.js, and is there afrom "@motifx/core"? (The compiler ran and the core stayed outside.) npx publintandnpx @arethetypeswrong/cli --pack: areexports/typesconsistent, and does a CJS consumer see the types?- Install the
npm packoutput in an empty Vite application and use a component; withapp.useDevelopment(true), check that noMJX121orMJX002warning appears. - When testing in a monorepo linked with
file:/link:, giveresolve: { dedupe: ['@motifx/core'] }on the consumer side; the published package does not need this. - In the
README, write down the peer dependencies, thestyle.cssimport, the prop table and the transition class names.
Next step
To inspect the component tree, reactivity and counters while developing a package, go on to The DevTools Contract.