Get started
Getting Started
Set up the build, write your first component and mount the application, with or without JSX.
This section covers setting up the MotifJS build chain, writing the first component and getting the application on screen. The examples come in two forms: with JSX (.tsx) and without JSX, using component calls directly (.ts).
Requirements
- Vite (the recommended bundler) or Rollup
- TypeScript or JavaScript. Views written with JSX live in
.tsx/.jsxfiles, and views written without JSX live in.ts/.jsfiles. If you use TypeScript, your project needs version 5.4 or later (the core's type declarations useNoInfer).@motifx/compilerbrings its own TypeScript dependency (5.x/6.x) for type-aware checks (motif-lint) and for reading the plugin'stsconfig.json, and it works independently of the version in your project. @motifx/core— the core library. It ships as ESM (dist/index.esm.js), CommonJS (dist/index.cjs) and, for use without a bundler and without JSX, UMD (dist/index.umd.min.js, the package'sunpkg/jsdelivrentry; see Without a bundler)@motifx/compiler— the build plugin. It compiles JSX into component calls, transforms decorators (@Injectable) in.ts/.jsfiles and strips development-only code from production builds. Install it even in a project that does not use JSX.- Node.js 18 or later — the
@motifx/compilerplugin and its commands (motif-lint,motif-explain) run on Node; your bundler's own Node requirement applies as well.
The two packages (@motifx/compiler and @motifx/core) are always published with the same version number, whether or not one of them changed, and they are installed at the same version. The surface that compiled code relies on in the runtime (the compiler contract) changes only in a major version. If the runtime does not support the contract number declared by the compiled code, development mode prints an MJX121 warning (the mismatch is noticed while modules load, but the warning appears when app.useDevelopment(true) is called).
Installation
New project
npm create motifjs@latest notes
pnpm create motifjs, yarn create motifjs, bun create motifjs and npx create-motifjs do the same thing. create-motifjs is the MotifJS community's project generator and needs Node 20.19 or later. It asks for the language (TypeScript or JavaScript), whether components are written with or without JSX, the AI agent docs and DevTools, and creates a Vite project with the compiler plugin and motif-lint already set up. Today the only template is starter, a complete notes application.
Adding to an existing project
The core is a dependency of the application, and the compiler is a development dependency:
npm install @motifx/core
npm install -D @motifx/compiler
With pnpm, yarn and bun the same two packages are installed with add, the compiler again as a development dependency. Then make the two settings in Build setup.
Without a bundler
The core's UMD file is the package's unpkg / jsdelivr entry and exposes every export on the global motif object:
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>MotifJS App</title></head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/@motifx/core"></script>
<script src="/main.js"></script>
</body>
</html>
main.js starts with const { Application, motifComponent, reactive } = motif;. JSX and decorator syntax need the compiler, so here the view is written without JSX, services are registered with builder.services, and code marked @DEV-ONLY is not stripped. In production, pin the address to a version: https://unpkg.com/@motifx/core@<version>.
Build setup
MotifJS JSX is not handled by a standard jsxFactory but by its own compiler (@motifx/compiler). The compiler turns every JSX element into a motifComponent call, and the reactive expressions inside it (text, conditions, lists) into bindings.* calls. That is why two settings are needed:
- Add the
@motifx/compilerplugin to the bundler. The plugin runs before Vite's own transform (enforce: 'pre') and leaves no JSX behind, so Vite needs no separate JSX setting. - Set
"jsx": "preserve"intsconfig.json: TypeScript checks the types of the JSX but does not compile it. A project without JSX does not need this line.
import { defineConfig } from 'vite';
import compiler from '@motifx/compiler';
export default defineConfig({
plugins: [compiler()],
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
server: { port: 3000 },
});
import { defineConfig } from 'vite';
import compiler from '@motifx/compiler';
export default defineConfig({
plugins: [compiler()],
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js'],
},
server: { port: 3000 },
});
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"useDefineForClassFields": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src"]
}
Why
jsx: "preserve"? In MotifJS, JSX does not turn into a "render function call" but into compiled output that sets up reactive bindings. For example, the compiler turns the expression{model.count}into asender.bindings.add("textContent", model, "count")call. Only@motifx/compilerknows this transform; TypeScript only checks the types, and@motifx/compileris what compiles the JSX.
The plugin compiles .jsx and .tsx files. In .ts/.js files it transforms decorators (@Injectable) (see Registering Services), names module-level stores for DevTools during vite dev, and strips development-only code from production builds. In .ts/.mts/.cts files it adds a static elementTag from an element generic such as Component<HTMLUListElement> (see Root element). In files under node_modules, only development-only code is stripped. In Vite 8 (Rolldown), if the application has not set optimizeDeps.rolldownOptions.transform.jsx for dependency scanning, the plugin sets it to 'preserve', so .jsx files are read without looking for another JSX runtime.
Rollup uses the same compiler() plugin; there is no separate Rollup or Babel plugin. Rollup has no mode of its own, so tell the plugin about production builds: compiler({ production: !process.env.ROLLUP_WATCH }). The plugin also strips types from the .tsx and .jsx files it compiles. Stripping types from plain .ts files and resolving imports written without an extension or by package name is the job of the usual Rollup plugins: rollup-plugin-esbuild or @rollup/plugin-typescript, plus @rollup/plugin-node-resolve with the .tsx and .ts extensions added. The vite/client types and import.meta.env are specific to Vite.
Development-only code
A production build strips code marked as development-only from application files and from packages under node_modules. MotifJS marks its own DevTools hooks this way, so this code never makes it into a production bundle, whatever app.useDevelopment() says. The app.useDevelopment(true) line is written with the same mark, so the production bundle contains neither that line nor the DevTools object.
/* @DEV-ONLY */ app.useDevelopment(true);
/* @DEV-ONLY-BEGIN */
app.useReactiveMonitor({ enabled: true, threshold: 200, name: 'app' });
(globalThis as any).app = app;
/* @DEV-ONLY-END */
/* @DEV-ONLY */ app.useDevelopment(true);
/* @DEV-ONLY-BEGIN */
app.useReactiveMonitor({ enabled: true, threshold: 200, name: 'app' });
(globalThis as any).app = app;
/* @DEV-ONLY-END */
/* @DEV-ONLY */ app.useDevelopment(true);
/* @DEV-ONLY-BEGIN */
app.useReactiveMonitor({ enabled: true, threshold: 200, name: 'app' });
globalThis.app = app;
/* @DEV-ONLY-END */
/* @DEV-ONLY */ app.useDevelopment(true);
/* @DEV-ONLY-BEGIN */
app.useReactiveMonitor({ enabled: true, threshold: 200, name: 'app' });
globalThis.app = app;
/* @DEV-ONLY-END */
The single-line mark strips the statement or class member that follows it. To strip several statements or members in a row, use the @DEV-ONLY-BEGIN / @DEV-ONLY-END pair; the pair sits between statements and class members.
@DEV-ONLYstrips the single statement or class member (method, field, accessor) that comes right after it; everything between@DEV-ONLY-BEGINand@DEV-ONLY-END, the marks included, is stripped.- A mark is a block comment that contains only the mark; a
//line that mentions the mark is not a mark. @DEV-ONLYworks at any depth (inside a method, in front of anifor aconst).BEGINandENDsit in the same block, between statements, imports or class members. ABEGINwithoutEND, anENDwithoutBEGIN, a nestedBEGINor a range mark inside an expression stops the build with an error at the mark, invite devas well.- Production means
vite buildin Vite, and theproductionoption orNODE_ENV=productionin Rollup. In Vite the decision follows Vite'sisProductionvalue, that is,NODE_ENV=production: ifNODE_ENV=developmentis set in the environment,vite builddoes not strip the code either. Nothing is stripped during development.
Plugin options
compiler({
diagnostics: false,
explain: 'pages/Home',
exclude: 'legacy/',
storeNames: true,
production: true,
})
compiler({
diagnostics: false,
explain: 'pages/Home',
exclude: 'legacy/',
storeNames: true,
production: true,
})
| Option | Description |
|---|---|
diagnostics |
With false, compile-time warnings (MJX001–MJX004, MJX015, MJX016) are not printed; on by default (see Compile-time warnings). |
explain |
Prints what every JSX expression compiles to in the matching files: true means every file, a string matches paths that contain it, a RegExp uses test(id) (see What did this expression compile to?). |
exclude |
Files the plugin keeps its hands off: a string matches paths that contain it (includes), a RegExp uses test(id), or an (id) => boolean function. MotifJS does not compile the JSX in these files, which is left to Vite's own transform; in a production build the development-only code inside them is still stripped. |
storeNames |
Names module-level stores for DevTools; on by default in vite dev and off in production builds. In Rollup it is off and is turned on with storeNames: true. |
production |
Strips development-only code; the default is vite build in Vite and NODE_ENV=production elsewhere. |
HTML host
The application needs a root element to be mounted into, and a <script> tag loads the entry file:
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>MotifJS App</title></head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>MotifJS App</title></head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>MotifJS App</title></head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>MotifJS App</title></head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
First component
A settings page: SettingsService holds the theme and density, writes them to <html> as data-theme / data-density and saves them to localStorage. SettingsPage shows the options as buttons, the selected one is marked with aria-pressed, and a summary line follows the choice. The class, function and Options API forms use the same core, and each is written both with and without JSX.
import { reactive } from "@motifx/core";
export type Theme = "system" | "light" | "dark";
export type Density = "comfortable" | "compact";
export const THEMES: readonly Theme[] = ["system", "light", "dark"];
export const DENSITIES: readonly Density[] = ["comfortable", "compact"];
const STORAGE_KEY = "settings";
export class SettingsService {
readonly state = reactive({ theme: "system" as Theme, density: "comfortable" as Density });
constructor() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
if (THEMES.includes(saved.theme)) this.state.theme = saved.theme;
if (DENSITIES.includes(saved.density)) this.state.density = saved.density;
} catch {}
this.apply();
}
setTheme(theme: Theme) {
this.state.theme = theme;
this.save();
}
setDensity(density: Density) {
this.state.density = density;
this.save();
}
private save() {
this.apply();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: this.state.theme, density: this.state.density }));
} catch {}
}
private apply() {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
if (this.state.density === "comfortable") delete root.dataset.density;
else root.dataset.density = this.state.density;
}
}
import { reactive } from "@motifx/core";
export const THEMES = ["system", "light", "dark"];
export const DENSITIES = ["comfortable", "compact"];
const STORAGE_KEY = "settings";
export class SettingsService {
state = reactive({ theme: "system", density: "comfortable" });
constructor() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
if (THEMES.includes(saved.theme)) this.state.theme = saved.theme;
if (DENSITIES.includes(saved.density)) this.state.density = saved.density;
} catch {}
this.apply();
}
setTheme(theme) {
this.state.theme = theme;
this.save();
}
setDensity(density) {
this.state.density = density;
this.save();
}
save() {
this.apply();
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme: this.state.theme, density: this.state.density }));
} catch {}
}
apply() {
const root = document.documentElement;
if (this.state.theme === "system") delete root.dataset.theme;
else root.dataset.theme = this.state.theme;
if (this.state.density === "comfortable") delete root.dataset.density;
else root.dataset.density = this.state.density;
}
}
import { Component } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export class SettingsPage extends Component {
settings = this.getService(SettingsService)!;
override view() {
return (
<main class="page">
<h1>Settings</h1>
{this.setting("Theme", THEMES, (theme) => this.settings.state.theme === theme, (theme) => this.settings.setTheme(theme))}
{this.setting("Density", DENSITIES, (density) => this.settings.state.density === density, (density) => this.settings.setDensity(density))}
<p>{() => `Theme ${LABELS[this.settings.state.theme]}, density ${LABELS[this.settings.state.density]}.`}</p>
</main>
);
}
private setting<T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) {
return (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
}
}
import { FromService } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export function SettingsPage() {
const settings = FromService(SettingsService)!;
const setting = <T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) => (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
return (
<main class="page">
<h1>Settings</h1>
{setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme))}
{setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density))}
<p>{() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`}</p>
</main>
);
}
import { FromService, motifOptions } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export const SettingsPage = () => {
const settings = FromService(SettingsService)!;
return motifOptions({
el: "main",
view() {
this.class.add("page");
return (
<>
<h1>Settings</h1>
{this.setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme))}
{this.setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density))}
<p>{() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`}</p>
</>
);
},
setting<T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) {
return (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
},
});
};
import { Component, ComponentBase, motifComponent } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export class SettingsPage extends Component {
settings = this.getService(SettingsService)!;
override view() {
return motifComponent("main", (main: ComponentBase) => {
main.class.add("page");
const heading = motifComponent("h1", (h1: ComponentBase) => h1.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Settings"))));
const summary = motifComponent("p", (p: ComponentBase) => {
p.bindings.text(() => `Theme ${LABELS[this.settings.state.theme]}, density ${LABELS[this.settings.state.density]}.`);
});
main.controls.add(
heading,
this.setting("Theme", THEMES, (theme) => this.settings.state.theme === theme, (theme) => this.settings.setTheme(theme)),
this.setting("Density", DENSITIES, (density) => this.settings.state.density === density, (density) => this.settings.setDensity(density)),
summary,
);
});
}
private setting<T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) {
return motifComponent("section", (section: ComponentBase) => {
section.class.add("setting");
const label = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(name))));
const group = motifComponent("div", (seg: ComponentBase) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
}
}
import { ComponentBase, FromService, motifComponent } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export function SettingsPage() {
const settings = FromService(SettingsService)!;
const setting = <T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) =>
motifComponent("section", (section: ComponentBase) => {
section.class.add("setting");
const label = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(name))));
const group = motifComponent("div", (seg: ComponentBase) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
return motifComponent("main", (main: ComponentBase) => {
main.class.add("page");
const heading = motifComponent("h1", (h1: ComponentBase) => h1.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Settings"))));
const summary = motifComponent("p", (p: ComponentBase) => {
p.bindings.text(() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`);
});
main.controls.add(
heading,
setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme)),
setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density)),
summary,
);
});
}
import { ComponentBase, FromService, motifComponent, motifOptions } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES, type Density, type Theme } from "../services/settings";
const LABELS: Record<Theme | Density, string> = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export const SettingsPage = () => {
const settings = FromService(SettingsService)!;
return motifOptions({
el: "main",
view() {
this.class.add("page");
const heading = motifComponent("h1", (h1: ComponentBase) => h1.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Settings"))));
const summary = motifComponent("p", (p: ComponentBase) => {
p.bindings.text(() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`);
});
return [
heading,
this.setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme)),
this.setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density)),
summary,
];
},
setting<T extends Theme | Density>(name: string, options: readonly T[], isChosen: (option: T) => boolean, choose: (option: T) => void) {
return motifComponent("section", (section: ComponentBase) => {
section.class.add("setting");
const label = motifComponent("span", (span: ComponentBase) => span.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(name))));
const group = motifComponent("div", (seg: ComponentBase) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button: ComponentBase) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node: ComponentBase) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
},
});
};
import { Component } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export class SettingsPage extends Component {
settings = this.getService(SettingsService);
view() {
return (
<main class="page">
<h1>Settings</h1>
{this.setting("Theme", THEMES, (theme) => this.settings.state.theme === theme, (theme) => this.settings.setTheme(theme))}
{this.setting("Density", DENSITIES, (density) => this.settings.state.density === density, (density) => this.settings.setDensity(density))}
<p>{() => `Theme ${LABELS[this.settings.state.theme]}, density ${LABELS[this.settings.state.density]}.`}</p>
</main>
);
}
setting(name, options, isChosen, choose) {
return (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
}
}
import { FromService } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export function SettingsPage() {
const settings = FromService(SettingsService);
const setting = (name, options, isChosen, choose) => (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
return (
<main class="page">
<h1>Settings</h1>
{setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme))}
{setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density))}
<p>{() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`}</p>
</main>
);
}
import { FromService, motifOptions } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export const SettingsPage = () => {
const settings = FromService(SettingsService);
return motifOptions({
el: "main",
view() {
this.class.add("page");
return (
<>
<h1>Settings</h1>
{this.setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme))}
{this.setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density))}
<p>{() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`}</p>
</>
);
},
setting(name, options, isChosen, choose) {
return (
<section class="setting">
<span>{name}</span>
<div class="seg" role="group" aria-label={name}>
{options.map((option) => (
<button key={option} type="button" aria-pressed={() => isChosen(option)} onclick={() => choose(option)}>
{LABELS[option]}
</button>
))}
</div>
</section>
);
},
});
};
import { Component, motifComponent } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export class SettingsPage extends Component {
settings = this.getService(SettingsService);
view() {
return motifComponent("main", (main) => {
main.class.add("page");
const heading = motifComponent("h1", (h1) => h1.controls.add(motifComponent("text", (node) => node.setText("Settings"))));
const summary = motifComponent("p", (p) => {
p.bindings.text(() => `Theme ${LABELS[this.settings.state.theme]}, density ${LABELS[this.settings.state.density]}.`);
});
main.controls.add(
heading,
this.setting("Theme", THEMES, (theme) => this.settings.state.theme === theme, (theme) => this.settings.setTheme(theme)),
this.setting("Density", DENSITIES, (density) => this.settings.state.density === density, (density) => this.settings.setDensity(density)),
summary,
);
});
}
setting(name, options, isChosen, choose) {
return motifComponent("section", (section) => {
section.class.add("setting");
const label = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.setText(name))));
const group = motifComponent("div", (seg) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
}
}
import { FromService, motifComponent } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export function SettingsPage() {
const settings = FromService(SettingsService);
const setting = (name, options, isChosen, choose) =>
motifComponent("section", (section) => {
section.class.add("setting");
const label = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.setText(name))));
const group = motifComponent("div", (seg) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
return motifComponent("main", (main) => {
main.class.add("page");
const heading = motifComponent("h1", (h1) => h1.controls.add(motifComponent("text", (node) => node.setText("Settings"))));
const summary = motifComponent("p", (p) => {
p.bindings.text(() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`);
});
main.controls.add(
heading,
setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme)),
setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density)),
summary,
);
});
}
import { FromService, motifComponent, motifOptions } from "@motifx/core";
import { DENSITIES, SettingsService, THEMES } from "../services/settings";
const LABELS = { system: "System", light: "Light", dark: "Dark", comfortable: "Comfortable", compact: "Compact" };
export const SettingsPage = () => {
const settings = FromService(SettingsService);
return motifOptions({
el: "main",
view() {
this.class.add("page");
const heading = motifComponent("h1", (h1) => h1.controls.add(motifComponent("text", (node) => node.setText("Settings"))));
const summary = motifComponent("p", (p) => {
p.bindings.text(() => `Theme ${LABELS[settings.state.theme]}, density ${LABELS[settings.state.density]}.`);
});
return [
heading,
this.setting("Theme", THEMES, (theme) => settings.state.theme === theme, (theme) => settings.setTheme(theme)),
this.setting("Density", DENSITIES, (density) => settings.state.density === density, (density) => settings.setDensity(density)),
summary,
];
},
setting(name, options, isChosen, choose) {
return motifComponent("section", (section) => {
section.class.add("setting");
const label = motifComponent("span", (span) => span.controls.add(motifComponent("text", (node) => node.setText(name))));
const group = motifComponent("div", (seg) => {
seg.class.add("seg");
seg.attr.add({ role: "group", "aria-label": name });
for (const option of options) {
const button = motifComponent("button", (button) => {
button.attr.add({ type: "button", "aria-pressed": () => isChosen(option) });
button.motif.on("click", () => choose(option));
button.controls.add(motifComponent("text", (node) => node.setText(LABELS[option])));
});
seg.controls.add(button);
}
});
section.controls.add(label, group);
});
},
});
};
In the example:
aria-pressed={() => …}is a getter: it is recomputed on all three buttons on every choice, and the ones whose value changes are the previous and the new choice. A plain value (type="button") is written once.{() => …}/bindings.text(() => …)is a live text binding. In JSX, a member expression such as{LABELS[option]}is a binding too, because the compiler turns it into a getter; in the form without JSX,setText(LABELS[option])writes the text once.- The service is registered in the
mainfile withbuilder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true }). A singleton givenstartup: trueis created at the start ofapp.run()without waiting for a component to ask for it; if it was asked for earlier, it is not created again. In the example the class component gets the service withthis.getService, and the function and Options components withFromService(the Options component can usethis.getServicetoo). Because the service is a singleton, the page sees the same settings every time it opens. Registering through@Injectableis covered in Registering Services. - In JSX,
.mapcompiles to a list binding. The options are fixed, so the form without JSX adds the buttons with a plain loop; for a list that changes, usebindings.list(see Conditional Rendering and Lists). - In the form without JSX, plain text is a
"text"component:motifComponent("text", (node) => node.setText("…")). The page class does not declare an element;view()returns the content, andmotifComponent("main", …)is the single element of that content, not the component's own element (see Root element). In the Options form the component's element is given withel, andview()returns the content.
Mount the application
The Application class is the application's entry point. The usual flow:
- Create a builder with
Application.CreateBuilder(). - Register services on
builder.servicesif needed (see Introduction to Dependency Injection). - Produce the
Applicationinstance withbuilder.build(). - Optionally define routing with
app.useRouter(...). - Mount the application on a DOM element with
app.run(host, rootComponent).
A class component is passed as an instance; it can also be built with the <SettingsPage /> tag in JSX or with motifComponent(SettingsPage) when writing without JSX. Function and Options components are written as tags in JSX; without JSX, a function component is called and an Options component is built with motifComponent(SettingsPage):
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new SettingsPage());
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", <SettingsPage />);
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new SettingsPage());
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", SettingsPage());
import { Application, motifComponent } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", motifComponent(SettingsPage));
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new SettingsPage());
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", <SettingsPage />);
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", new SettingsPage());
import { Application } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", SettingsPage());
import { Application, motifComponent } from "@motifx/core";
import { SettingsPage } from "./pages/SettingsPage";
import { SettingsService } from "./services/settings";
const builder = Application.CreateBuilder();
builder.services.addSingleton(SettingsService, { useClass: SettingsService, startup: true });
const app = builder.build();
app.run("#app", motifComponent(SettingsPage));
The first argument of app.run accepts a CSS selector ("#app"), an HTMLElement or a Node; if the selector matches nothing, an MJX406 error is thrown. The second argument is the root component. The host's existing content is kept, and the root component is appended after it. If the second argument is left out, MotifJS places a RouterView at the root by itself (for router-based applications; see below). Application.CreateBuilder() is called once per application; a second call throws MJX405 (it can be called again after app.dispose()).
Router-based startup
In multi-page applications, use routing instead of a root component. In that case do not pass a second argument to run; MotifJS adds the root RouterView itself. When a second argument is given, the root is your component and you put the RouterView in its view yourself:
import { Application } from "@motifx/core";
import { routes } from "./routes";
const app = Application.CreateBuilder().build();
/* @DEV-ONLY */ app.useDevelopment(true);
app.useLogging(true);
app.useRouter({
routes,
mode: "history",
fallbacks: {
notFound: () => import("./pages/NotFoundPage"),
},
});
app.run("#app");
import { Application } from "@motifx/core";
import { routes } from "./routes";
const app = Application.CreateBuilder().build();
/* @DEV-ONLY */ app.useDevelopment(true);
app.useLogging(true);
app.useRouter({
routes,
mode: "history",
fallbacks: {
notFound: () => import("./pages/NotFoundPage"),
},
});
app.run("#app");
useDevelopment turns on development mode: MJX warnings, route checks when it is called before useRouter, and DevTools. useLogging turns on console error logging (on by default). The mode values are 'history' | 'hash' | 'file' | 'shell'. Route definitions are covered in detail on the Route Table page, and placing a RouterView on the RouterView page. The module loaded through notFound must export the page as default; for a named export, map it: notFound: () => import("./pages/NotFoundPage").then((m) => m.NotFoundPage). A module without a default export is reported as MJX127 (the cause of MJX304), and the error view is shown instead of the 404 page (see Lazy loading).
Development helpers
Application has chainable configuration methods:
| Method | Description |
|---|---|
app.useDevelopment(true) |
Turns on development mode: MJX warnings are written to the console (console.warn), and so are errors the framework catches internally and passes over quietly; when called before useRouter, route definitions are checked. MJX warnings are not buffered: warnings raised before development mode is turned on (other than MJX121) are not printed later, which is why useDevelopment is called before run. DevTools is turned on as well: the globalThis.__MOTIF_DEVTOOLS__ object is created (The DevTools Contract); useDevelopment(false) turns DevTools off and removes the object. Read it with app.isDevelopmentModeEnabled. In a production build the core's DevTools hooks are stripped from the code (Development-only code), so DevTools produces no data. |
app.useLogging(true) |
Turns writing errors to the console on or off (on by default). It does not turn off MJX warnings; those follow development mode (useDevelopment). |
app.useReactiveMonitor({ enabled: true, threshold: 200, name: 'app' }) |
Turns on tracking of reactive dependency leaks (recommended during development; when the threshold is exceeded an MJX507 warning is printed, and this warning only shows while useDevelopment(true) is on; see Leak tracking). |
app.useTransitions({ mode: 'out-in' }) |
Picks the default mode for enter/leave transitions (see Enter/leave mode). |
Errors thrown in hooks (MJX122), event handlers (MJX123), effect/binding functions (MJX208) and using handlers (MJX129) are reported regardless of development mode, in production too: they are written to console.error (unless useLogging(false)) and passed to errorHandler.addListener(fn) listeners as a MotifError (error.code, error.cause; see Error Handling). The application keeps running.
Next step
To build the parts of the application, go on to Components.