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" }
}

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:

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';

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:

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';

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

Pre-publish checks

  1. npm run build → were dist/index.js, dist/index.cjs, dist/types/index.d.ts and dist/style.css created?
  2. Is there no jsx(/createElement( in dist/index.js, and is there a from "@motifx/core"? (The compiler ran and the core stayed outside.)
  3. npx publint and npx @arethetypeswrong/cli --pack: are exports/types consistent, and does a CJS consumer see the types?
  4. Install the npm pack output in an empty Vite application and use a component; with app.useDevelopment(true), check that no MJX121 or MJX002 warning appears.
  5. When testing in a monorepo linked with file:/link:, give resolve: { dedupe: ['@motifx/core'] } on the consumer side; the published package does not need this.
  6. In the README, write down the peer dependencies, the style.css import, 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.