Application structure

Styling and Animation

The class/style/attr helpers, inline <style>, the CSS class based transition prop, WAAPI keyframe transitions, visibility and the enter/leave mode.

Ways to style

class

An element's class is given with the class (or className) attribute in JSX, and with the element's class.add call without JSX. The value can be a string, an array, an object or a getter:

<a class="row" />
<a class={() => `row${open() ? " active" : ""}`} />
<a class={{ row: true, active: open(), untitled: !note.title.trim() }} />
const fixed = motifComponent("a", (row: ComponentBase) => row.class.add("row"));
const active = motifComponent("a", (row: ComponentBase) => row.class.add(() => `row${open() ? " active" : ""}`));
const flags = motifComponent("a", (row: ComponentBase) => row.class.add({ row: true, active: () => open(), untitled: () => !note.title.trim() }));
<a class="row" />
<a class={() => `row${open() ? " active" : ""}`} />
<a class={{ row: true, active: open(), untitled: !note.title.trim() }} />
const fixed = motifComponent("a", (row) => row.class.add("row"));
const active = motifComponent("a", (row) => row.class.add(() => `row${open() ? " active" : ""}`));
const flags = motifComponent("a", (row) => row.class.add({ row: true, active: () => open(), untitled: () => !note.title.trim() }));

In an object the key is the class name and the value is the condition: if it is truthy the class is added, otherwise it is removed.

On a plain tag, constants such as true/false are applied once, and other values are wrapped in a getter and stay live. On a component tag the compiler wraps only the ternary; there, pass a live condition as a function (see JSX: class).

In an object passed to class.add, a value that is not a function is read once, at the time of the call; for a live condition, pass a function (see JSX: class).

style

Inline style is given with the style attribute in JSX, and with the element's style call without JSX. The value can be a string, an object or a getter; when a value the getter reads changes, the style is written again (see JSX: style):

<a style="opacity: 0.6;" />
<a style={{ borderLeft: "3px solid var(--accent)" }} />
<a style={() => ({ opacity: note.archived ? 0.6 : 1 })} />
const dimmed = motifComponent("a", (row: ComponentBase) => row.style("opacity: 0.6;"));
const marked = motifComponent("a", (row: ComponentBase) => row.style({ borderLeft: "3px solid var(--accent)" }));
const live = motifComponent("a", (row: ComponentBase) => row.style(() => ({ opacity: note.archived ? 0.6 : 1 })));
<a style="opacity: 0.6;" />
<a style={{ borderLeft: "3px solid var(--accent)" }} />
<a style={() => ({ opacity: note.archived ? 0.6 : 1 })} />
const dimmed = motifComponent("a", (row) => row.style("opacity: 0.6;"));
const marked = motifComponent("a", (row) => row.style({ borderLeft: "3px solid var(--accent)" }));
const live = motifComponent("a", (row) => row.style(() => ({ opacity: note.archived ? 0.6 : 1 })));

Inline <style>

You can give CSS directly inside view() with a <style> block; without JSX, this is a "style" element whose text is the CSS. The rules are not limited to the component; they apply to the whole document:

import { Component } from "@motifx/core";

export class EmptyState extends Component {
  override view() {
    return (
      <div class="placeholder">
        <style>{`
          .placeholder { display: grid; place-items: center; padding: 2rem; }
          .placeholder p { color: var(--muted); }
        `}</style>
        <p>No notes yet.</p>
      </div>
    );
  }
}
export function EmptyState() {
  return (
    <div class="placeholder">
      <style>{`
        .placeholder { display: grid; place-items: center; padding: 2rem; }
        .placeholder p { color: var(--muted); }
      `}</style>
      <p>No notes yet.</p>
    </div>
  );
}
import { motifOptions } from "@motifx/core";

export const EmptyState = () => motifOptions({
  el: "div",

  onConfigured() {
    this.class.add("placeholder");
  },

  view() {
    return (
      <>
        <style>{`
          .placeholder { display: grid; place-items: center; padding: 2rem; }
          .placeholder p { color: var(--muted); }
        `}</style>
        <p>No notes yet.</p>
      </>
    );
  },
});
import { Component, ComponentBase, motifComponent } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export class EmptyState extends Component {
  override view() {
    return motifComponent("div", (root: ComponentBase) => {
      root.class.add("placeholder");
      const style = motifComponent("style", (tag: ComponentBase) => tag.setText(css));
      const text = motifComponent("p", (p: ComponentBase) => p.setText("No notes yet."));
      root.controls.add(style, text);
    });
  }
}
import { ComponentBase, motifComponent } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export function EmptyState() {
  return motifComponent("div", (root: ComponentBase) => {
    root.class.add("placeholder");
    const style = motifComponent("style", (tag: ComponentBase) => tag.setText(css));
    const text = motifComponent("p", (p: ComponentBase) => p.setText("No notes yet."));
    root.controls.add(style, text);
  });
}
import { ComponentBase, motifComponent, motifOptions } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export const EmptyState = () => motifOptions({
  el: "div",

  onConfigured() {
    this.class.add("placeholder");
  },

  view() {
    const style = motifComponent("style", (tag: ComponentBase) => tag.setText(css));
    const text = motifComponent("p", (p: ComponentBase) => p.setText("No notes yet."));
    return [style, text];
  },
});
import { Component } from "@motifx/core";

export class EmptyState extends Component {
  view() {
    return (
      <div class="placeholder">
        <style>{`
          .placeholder { display: grid; place-items: center; padding: 2rem; }
          .placeholder p { color: var(--muted); }
        `}</style>
        <p>No notes yet.</p>
      </div>
    );
  }
}
export function EmptyState() {
  return (
    <div class="placeholder">
      <style>{`
        .placeholder { display: grid; place-items: center; padding: 2rem; }
        .placeholder p { color: var(--muted); }
      `}</style>
      <p>No notes yet.</p>
    </div>
  );
}
import { motifOptions } from "@motifx/core";

export const EmptyState = () => motifOptions({
  el: "div",

  onConfigured() {
    this.class.add("placeholder");
  },

  view() {
    return (
      <>
        <style>{`
          .placeholder { display: grid; place-items: center; padding: 2rem; }
          .placeholder p { color: var(--muted); }
        `}</style>
        <p>No notes yet.</p>
      </>
    );
  },
});
import { Component, motifComponent } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export class EmptyState extends Component {
  view() {
    return motifComponent("div", (root) => {
      root.class.add("placeholder");
      const style = motifComponent("style", (tag) => tag.setText(css));
      const text = motifComponent("p", (p) => p.setText("No notes yet."));
      root.controls.add(style, text);
    });
  }
}
import { motifComponent } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export function EmptyState() {
  return motifComponent("div", (root) => {
    root.class.add("placeholder");
    const style = motifComponent("style", (tag) => tag.setText(css));
    const text = motifComponent("p", (p) => p.setText("No notes yet."));
    root.controls.add(style, text);
  });
}
import { motifComponent, motifOptions } from "@motifx/core";

const css = `
  .placeholder { display: grid; place-items: center; padding: 2rem; }
  .placeholder p { color: var(--muted); }
`;

export const EmptyState = () => motifOptions({
  el: "div",

  onConfigured() {
    this.class.add("placeholder");
  },

  view() {
    const style = motifComponent("style", (tag) => tag.setText(css));
    const text = motifComponent("p", (p) => p.setText("No notes yet."));
    return [style, text];
  },
});

Classes and styles in code

After setup, classes and styles change through the component's class and style helpers:

this.class.add("active");
this.class.add("row", "pinned");
this.class.add(() => (note.pinned ? "pinned" : ""));
this.class.add({ active: () => open() });
this.class.remove("active");
this.class.remove("**");
this.class.has("row");

this.style({ opacity: 0.6 });
this.style("opacity: 0.6; font-weight: bold;");
this.style(() => ({ opacity: note.archived ? 0.6 : 1 }));
row.class.add("active");
row.class.add("row", "pinned");
row.class.add(() => (note.pinned ? "pinned" : ""));
row.class.add({ active: () => open() });
row.class.remove("active");
row.class.remove("**");
row.class.has("row");

row.style({ opacity: 0.6 });
row.style("opacity: 0.6; font-weight: bold;");
row.style(() => ({ opacity: note.archived ? 0.6 : 1 }));
this.class.add("active");
this.class.add("row", "pinned");
this.class.add(() => (note.pinned ? "pinned" : ""));
this.class.add({ active: () => open() });
this.class.remove("active");
this.class.remove("**");
this.class.has("row");

this.style({ opacity: 0.6 });
this.style("opacity: 0.6; font-weight: bold;");
this.style(() => ({ opacity: note.archived ? 0.6 : 1 }));
row.class.add("active");
row.class.add("row", "pinned");
row.class.add(() => (note.pinned ? "pinned" : ""));
row.class.add({ active: () => open() });
row.class.remove("active");
row.class.remove("**");
row.class.has("row");

row.style({ opacity: 0.6 });
row.style("opacity: 0.6; font-weight: bold;");
row.style(() => ({ opacity: note.archived ? 0.6 : 1 }));

In a function component these calls are made on the component itself (row in the example): in JSX, the component that reaches the root's onConfig hook or ref, and without JSX, the component received by the function of the motifComponent call that builds the root.

class.remove("**") removes every class and every live source, and class.has tells whether the element has the class. class.add keeps reference counting: if the same class is added from several sources, the class stays until all of them are removed. In an object passed to class.add, a value that is not a function is read once, at the time of the call; for a live condition, pass a function. The object form of style changes only the properties it gives, while the string form replaces the whole inline style.

The attribute helper — attr

Attributes are written in code with the component's attr helper:

this.attr.add({ href: `/notes/${note.id}`, title: note.title });
row.attr.add({ href: `/notes/${note.id}`, title: note.title });
this.attr.add({ href: `/notes/${note.id}`, title: note.title });
row.attr.add({ href: `/notes/${note.id}`, title: note.title });

Attributes written on a tag in JSX are also attr.add calls in the compiler output; the value rules below are the same for both.

The RouterLink component adds its href this way too (unless showHref: false is given).

attr.add values: false, null and undefined remove the attribute, true writes it with an empty value (""), and strings and numbers are written as they are. On aria-* and data-* attributes true/false are written as text ("true"/"false"). If a key in the object is null, only that attribute is removed and the other keys are written (attr.add({ title: null, role: "tab" })).

Attribute names are only the object's own keys passed to attr.add; the value is not looked into. An object or array value is turned into text and written as a single attribute value: { title: { a: 1 } } → title="[object Object]", { 'data-ids': [1, 2] } → data-ids="1,2". Objects and arrays returned by a getter are written the same way. For a key whose name is the same as an element method, the value is that method's argument (see Element methods).

Tailwind, Bootstrap and external CSS

MotifJS is not tied to any CSS solution; the classes of a CSS library are given like any other class:

<a rel="router" href="/notes" class="btn btn-primary">Notes</a>
const link = motifComponent("a", (a: ComponentBase) => {
  a.attr.add({ rel: "router", href: "/notes" });
  a.class.add("btn btn-primary");
  a.controls.add(motifComponent("text", (node: ComponentBase) => node.setText("Notes")));
});
<a rel="router" href="/notes" class="btn btn-primary">Notes</a>
const link = motifComponent("a", (a) => {
  a.attr.add({ rel: "router", href: "/notes" });
  a.class.add("btn btn-primary");
  a.controls.add(motifComponent("text", (node) => node.setText("Notes")));
});

For Tailwind, add the @tailwindcss/vite plugin to vite.config.ts.

The transition system

MotifJS supports two kinds of enter/leave animation:

  1. CSS class based transitions — with the transition prop.
  2. WAAPI (Web Animations API) keyframes — in code, with motif.options.transition.in/out.

Transitions play automatically when a component is added to the DOM (enter) and when it is removed or hidden (leave).

CSS class based transitions — the transition prop

The transition is given a name: the transition attribute in JSX, and the transition key of the props object without JSX. The pin mark in the example is shown and hidden as the note is pinned and unpinned, and the transition plays both ways:

<i class="pin" transition="fade" x-display={() => note.pinned} />
const pin = motifComponent("i", {
  preconfig: (i: ComponentBase) => i.bindings.display(() => note.pinned),
  transition: "fade",
  initializeComponent: (i: ComponentBase) => i.class.add("pin"),
});
<i class="pin" transition="fade" x-display={() => note.pinned} />
const pin = motifComponent("i", {
  preconfig: (i) => i.bindings.display(() => note.pinned),
  transition: "fade",
  initializeComponent: (i) => i.class.add("pin"),
});

This applies the following CSS classes at the right stages:

Stage Classes applied
Enter fade-enter-from → fade-enter-active → fade-enter-to
Leave fade-leave-from → fade-leave-active → fade-leave-to

You write the matching CSS:

.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from,   .fade-leave-to     { opacity: 0; }
.fade-enter-to,     .fade-leave-from   { opacity: 1; }

transition works the same way on a plain element, a class component and a function component; on a function component it is applied to the returned root, and a value given from outside takes precedence over the value the function writes on its own root. The value can also be a getter: it is tracked for as long as the component lives, and if the getter returns null or an empty string the transition is turned off. In JSX, a ternary written on a tag compiles to a getter on its own; without JSX you pass the getter yourself:

<FolderBadge folder="notes" transition={this.settings.state.density === "compact" ? "fade-fast" : "fade"} />
<FolderBadge folder="notes" transition={settings.state.density === "compact" ? "fade-fast" : "fade"} />
const badge = motifComponent(FolderBadge, {
  folder: "notes",
  transition: () => (this.settings.state.density === "compact" ? "fade-fast" : "fade"),
});
const badge = motifComponent(FolderBadge, {
  folder: "notes",
  transition: () => (settings.state.density === "compact" ? "fade-fast" : "fade"),
});
<FolderBadge folder="notes" transition={this.settings.state.density === "compact" ? "fade-fast" : "fade"} />
<FolderBadge folder="notes" transition={settings.state.density === "compact" ? "fade-fast" : "fade"} />
const badge = motifComponent(FolderBadge, {
  folder: "notes",
  transition: () => (this.settings.state.density === "compact" ? "fade-fast" : "fade"),
});
const badge = motifComponent(FolderBadge, {
  folder: "notes",
  transition: () => (settings.state.density === "compact" ? "fade-fast" : "fade"),
});

In the function component, settings is the service the component gets with FromService(SettingsService).

Custom class names and duration — the object form

Class names and the duration are customized by giving transition a TransitionProps object:

<div
  class="confirm"
  transition={{
    name: "slide",
    duration: { enter: 300, leave: 200 },
    enterFromClass: "slide-in-start",
    enterActiveClass: "slide-in-active",
    enterToClass: "slide-in-end",
    leaveFromClass: "slide-out-start",
    leaveActiveClass: "slide-out-active",
    leaveToClass: "slide-out-end",
  }}
>
  …
</div>
const confirm = motifComponent("div", {
  transition: {
    name: "slide",
    duration: { enter: 300, leave: 200 },
    enterFromClass: "slide-in-start",
    enterActiveClass: "slide-in-active",
    enterToClass: "slide-in-end",
    leaveFromClass: "slide-out-start",
    leaveActiveClass: "slide-out-active",
    leaveToClass: "slide-out-end",
  },
  initializeComponent: (div: ComponentBase) => div.class.add("confirm"),
});
<div
  class="confirm"
  transition={{
    name: "slide",
    duration: { enter: 300, leave: 200 },
    enterFromClass: "slide-in-start",
    enterActiveClass: "slide-in-active",
    enterToClass: "slide-in-end",
    leaveFromClass: "slide-out-start",
    leaveActiveClass: "slide-out-active",
    leaveToClass: "slide-out-end",
  }}
>
  …
</div>
const confirm = motifComponent("div", {
  transition: {
    name: "slide",
    duration: { enter: 300, leave: 200 },
    enterFromClass: "slide-in-start",
    enterActiveClass: "slide-in-active",
    enterToClass: "slide-in-end",
    leaveFromClass: "slide-out-start",
    leaveActiveClass: "slide-out-active",
    leaveToClass: "slide-out-end",
  },
  initializeComponent: (div) => div.class.add("confirm"),
});

WAAPI keyframe animation — motif.options.transition.in/out

For keyframe animations defined in code, the enter and leave animations are given with motif.options.transition.in / out, in the component's onConfig or initializeComponent stage:

<section
  onConfig={(panel) => {
    panel.motif.options.transition.in({
      keyframes: [{ opacity: 0, transform: "translateX(-50px)" }, { opacity: 1, transform: "translateX(0)" }],
      options: { duration: 300, easing: "ease-out", fill: "forwards" },
    });
    panel.motif.options.transition.out({
      keyframes: [{ opacity: 1, transform: "translateX(0)" }, { opacity: 0, transform: "translateX(-50px)" }],
      options: { duration: 200, easing: "ease-in", fill: "forwards" },
    });
  }}
>
  …
</section>
const panel = motifComponent("section", {
  onConfig: (panel: ComponentBase) => {
    panel.motif.options.transition.in({
      keyframes: [{ opacity: 0, transform: "translateX(-50px)" }, { opacity: 1, transform: "translateX(0)" }],
      options: { duration: 300, easing: "ease-out", fill: "forwards" },
    });
    panel.motif.options.transition.out({
      keyframes: [{ opacity: 1, transform: "translateX(0)" }, { opacity: 0, transform: "translateX(-50px)" }],
      options: { duration: 200, easing: "ease-in", fill: "forwards" },
    });
  },
});
<section
  onConfig={(panel) => {
    panel.motif.options.transition.in({
      keyframes: [{ opacity: 0, transform: "translateX(-50px)" }, { opacity: 1, transform: "translateX(0)" }],
      options: { duration: 300, easing: "ease-out", fill: "forwards" },
    });
    panel.motif.options.transition.out({
      keyframes: [{ opacity: 1, transform: "translateX(0)" }, { opacity: 0, transform: "translateX(-50px)" }],
      options: { duration: 200, easing: "ease-in", fill: "forwards" },
    });
  }}
>
  …
</section>
const panel = motifComponent("section", {
  onConfig: (panel) => {
    panel.motif.options.transition.in({
      keyframes: [{ opacity: 0, transform: "translateX(-50px)" }, { opacity: 1, transform: "translateX(0)" }],
      options: { duration: 300, easing: "ease-out", fill: "forwards" },
    });
    panel.motif.options.transition.out({
      keyframes: [{ opacity: 1, transform: "translateX(0)" }, { opacity: 0, transform: "translateX(-50px)" }],
      options: { duration: 200, easing: "ease-in", fill: "forwards" },
    });
  },
});

In a class component and in an Options object, the same calls are written in the component's own onConfigured hook as this.motif.options.transition.in(...) and out(...).

in/out use the standard WAAPI element.animate(keyframes, options) signature:

The in/out definition belongs to the component and is used on every later enter/leave; calling it again replaces the previous one. Running animations are kept in the motif.options.transition.activeAnimations array. When a new keyframe animation starts on the component, the running one is cancelled: if motif.hide() is called while the enter is running, the enter is cut off and the leave plays; if motif.show() is called while the leave is running, the leave is cut off and the enter plays.

Precedence: if both WAAPI (motif.options.transition.in/out) and a CSS class transition are defined on a component, the WAAPI keyframes take precedence.

Transitions and visibility

When motif.hide()/motif.show() is called, the matching leave/enter transition plays automatically. On leave, removal from the DOM happens after the leave animation finishes. If two calls come one after the other, the last one wins: if motif.hide() comes while the enter is running, the enter is cut off and the leave plays; if motif.show() comes while the leave is running, the leave is cut off, the element is not removed from its place and the enter transition plays from the start. The await motif.hide() promise of the interrupted leave resolves at that moment, and the component has stayed visible. The x-display and x-wait conditions follow the same rule: if the condition flips back before the leave finishes, the element is not removed. If the root is a fragment, the same goes for its children. To make the next leave happen without animation, turn on the component's skipNextLeave flag:

this.motif.options.transition.skipNextLeave = true;
await this.motif.hide();
panel.motif.options.transition.skipNextLeave = true;
await panel.motif.hide();
this.motif.options.transition.skipNextLeave = true;
await this.motif.hide();
panel.motif.options.transition.skipNextLeave = true;
await panel.motif.hide();

In the function component, panel is the component to hide: the component held through ref or received by the onConfig hook, and without JSX the component returned by motifComponent.

The flag affects only the next leave and is reset once used. If the root is a fragment, the visible children are hidden without animation as well.

A route page's transitions play during navigation too: the page that leaves finishes its leave transition, and then the incoming page enters. When the router is in stack mode (useRouter({ stack })) and stack.animation is not given (or is 'none'), these transitions are used as the stack transition, and the page element carries data-nav-direction for the duration of the enter transition. When stack.animation is given, the stack animation plays on push, back and forward navigations instead of the page's own enter transition; see Stack Navigation.

Enter and leave order — mode

When one child leaves a container while another enters, there are two modes:

Mode Behaviour
'concurrent' (default) The incoming child is placed right away and plays its enter, while the outgoing child's leave runs at the same time.
'out-in' The incoming child is not placed in the DOM until the leaves running in that container finish; when they finish, it is placed and plays its enter.
'in-out' The incoming child is placed right away and plays its enter; the outgoing child stays in place meanwhile and plays its leave once the incoming one's enter is finished.

The mode is given in three places, and the narrower one overrides the wider one. For the whole application, without needing the router:

app.useTransitions({ mode: "out-in" });
app.useTransitions({ mode: "out-in" });

Per container, with the container's transition value:

<main class="detail" transition={{ mode: "out-in" }}>…</main>
const detail = motifComponent("main", {
  transition: { mode: "out-in" },
  initializeComponent: (main: ComponentBase) => main.class.add("detail"),
});
<main class="detail" transition={{ mode: "out-in" }}>…</main>
const detail = motifComponent("main", {
  transition: { mode: "out-in" },
  initializeComponent: (main) => main.class.add("detail"),
});

or through the container's settings, in code:

this.motif.options.transition.mode = "concurrent";
detail.motif.options.transition.mode = "concurrent";
this.motif.options.transition.mode = "concurrent";
detail.motif.options.transition.mode = "concurrent";

A transition value that carries only mode sets the mode and adds no animation to the container itself; it can also be given together with name and class names. The mode applies to every child that comes to the container with controls.add: when/ternary/switch branches, Frame and Lazy content, list rows, components added by hand. The mode is read from the nearest element container: containers with a comment node, such as Frame and fragments, use their parent's mode if they have none of their own, so it also works when a conditional branch changes inside an element that carries the mode (see Frame timing). When switching between siblings with motif.hide() / motif.show(), show waits for the sibling's leave too. If a child that arrives while waiting is removed or hidden again, it is never placed. If no leave is running, out-in does not wait. A cancelled leave (Animation.cancel) releases the one waiting.

In in-out mode the outgoing child's leave waits one microtask to see the enters starting in the same operation; if the incoming child has no enter transition, or nothing comes in at all, the leave plays right away. If the outgoing child is being disposed, the dispose() Promise resolves when the leave finishes; the same goes for motif.hide(). A cancelled enter releases the leave waiting on it.

Stopping active animations

await this.motif.stopAnimations();
await panel.motif.stopAnimations();
await this.motif.stopAnimations();
await panel.motif.stopAnimations();

A running CSS class transition is finished right away, running WAAPI animations are cancelled, and activeAnimations is emptied. If the interrupted transition was a leave, the hiding completes at that moment: the element leaves the document and its marker stays in its place. The running animations of a component being disposed are cancelled the same way.

Next step

To manage the lifetime of components and resources, go on to Memory Management and Disposal.