Skip to content

How it works

mountly has three layers and one state machine.

Plain framework components. No special APIs, no decorators, no new model. You write the same code you write today.

src/SignupCard.tsx
import { useState } from "react";
export default function SignupCard({ headline }: { headline: string }) {
const [email, setEmail] = useState("");
return (
<form>
<h2>{headline}</h2>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Subscribe</button>
</form>
);
}

2. Widgets — framework-agnostic mounting units

Section titled “2. Widgets — framework-agnostic mounting units”

A framework adapter wraps the component into a widget: an object with mount(container, props) and unmount(container). The widget knows nothing about when it loads, only how to render.

signup-card.ts
import { createWidget } from "mountly-react";
import SignupCard from "./SignupCard.tsx";
import styles from "./SignupCard.css?inline";
export default createWidget(SignupCard, { styles });

Each adapter (React, Vue, Svelte) exposes the same createWidget(Component, { styles }) signature. By default the widget mounts in light DOM so the host’s design system applies. Pass shadow: true when you need a hard style boundary (CMS embeds, third-party hosts).

A feature wraps a widget with a trigger, a module loader, optional data fetching, dual caching, and a state machine. You use this layer in production.

signup-feature.ts
import { createOnDemandFeature } from "mountly";
export const signupFeature = createOnDemandFeature({
moduleId: "signup-card",
loadModule: () => import("./signup-card.js"),
loadData: async () => fetch("/api/copy").then((r) => r.json()),
render: ({ mod, data, container, props }) => {
mod.mount(container, { ...props, ...data });
},
});

Every feature flows through the same five states:

idle → preload → activate → mount → unmount
  • idle: nothing loaded. Waiting for a trigger.
  • preload: mountly fetches the module. A data fetch can begin in parallel.
  • activate: a commitment signal arrives (e.g. click). mountly finalises the data fetch and prepares to mount.
  • mount: mountly renders the widget into the container (light DOM by default; shadow root if shadow: true).
  • unmount: mountly removes the DOM. It retains the module and data caches, so re-mounting is free.

You can hook any phase, abort in flight (feature.abort()), and inspect the current state (feature.getState()). See Lifecycle for the full state diagram.

A trigger decides when a feature moves between states. Seven are built in:

Trigger Description Best for
hover Preload on mouseenter, mount on click. Discoverable controls.
click Mount on click without preload. Committed actions.
focus Preload on focus, mount on commit. Keyboard-accessible counterparts to hover.
viewport Mount when element enters viewport (configurable threshold). Below-the-fold content.
idle Use requestIdleCallback to preload during quiet moments. Predictive prefetch.
media Mount when a CSS media query matches. Responsive-only UI.
url-change Mount on popstate/hashchange/pushState/replaceState. Route-driven content.

You can also register trigger plugins, such as swipe, long-press, or keyboard chord, without touching the runtime.

mountly keeps two cooperating caches:

Stores downloaded JavaScript bundles, keyed by moduleId. It deduplicates in-flight requests: if 12 hovers fire before the first import resolves, you get one network request, not twelve.

Stores loadData responses, keyed by a stable serialisation of the feature context (excludes the DOM element and event fields by default). Override getCacheKey for coarser caching.

Both caches survive across re-mounts, so the second open of the same widget is instant.

When you build a widget, the CLI emits two ESM entries:

Includes the component, the adapter, and the framework. Drop it into any page and it works without host setup. A representative React widget weighs about 148 KB gz.

Excludes the framework. The host provides react, react-dom, react-dom/client, and react/jsx-runtime via an import map. Each widget weighs about 5 KB gz, plus one 45 KB gz copy of React shared across all of them.

The host picks which one to load via the import map. The widget source is identical either way. See Distribution for when each makes sense and installRuntime for the helper that wires the React import map for you.

Declarative usage uses a custom element:

<mountly-feature module-id="signup-card" trigger="viewport" props='{"plan":"pro"}' />

Register the factory once and the element handles attach/detach automatically. See Custom element.

You write mountly provides
Components (React/Vue/Svelte) Adapters that wrap them as widgets
createOnDemandFeature(...) config Lifecycle, abort, dual caching, in-flight dedup
Trigger choice (hover, viewport, …) Trigger setup, cleanup, cancellation
loadModule / loadData Module cache, data cache, error wrapping
<mountly-feature> markup Custom element wiring
  • Triggers: each trigger type in depth.
  • Lifecycle: the state diagram and abort behaviour.
  • Caching: cache keys, invalidation, in-flight dedup.
  • Distribution: self-contained vs shared React.