Skip to content

How it works

mountly has three layers and one state machine. That’s the whole concept.

Plain framework components. No special APIs, no decorators, no new model. Same code you’d 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’s loaded — 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. The widget always mounts into a shadow root, so styles are scoped and the host can’t bleed in.

A feature wraps a widget with a trigger, a module loader, optional data fetching, dual caching, and a state machine. This is the layer you actually use 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 — module is fetched. Optionally, data fetch begins in parallel.
  • activate — commitment signal received (e.g. click). Finalise data fetch, prepare to mount.
  • mount — widget rendered into the container’s shadow root.
  • unmount — DOM removed. Module + data caches are retained, 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:

TriggerDescriptionBest for
hoverPreload on mouseenter, mount on click.Discoverable controls.
clickMount on click without preload.Committed actions.
focusPreload on focus, mount on commit.Keyboard-accessible counterparts to hover.
viewportMount when element enters viewport (configurable threshold).Below-the-fold content.
idleUse requestIdleCallback to preload during quiet moments.Predictive prefetch.
mediaMount when a CSS media query matches.Responsive-only UI.
url-changeMount on popstate/hashchange/pushState/replaceState.Route-driven content.

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

mountly keeps two cooperating caches:

Stores downloaded JavaScript bundles, keyed by moduleId. 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; it works without any host setup. ~148 KB gz for a representative React widget.

Excludes the framework. Expects the host to provide react, react-dom, react-dom/client, and react/jsx-runtime via an import map. ~5 KB gz per widget plus one ~45 KB gz copy of React shared across all widgets.

The host picks which to load via the import map — the widget source is identical. 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 writemountly provides
Components (React/Vue/Svelte)Adapters that wrap them as widgets
createOnDemandFeature(...) configLifecycle, abort, dual caching, in-flight dedup
Trigger choice (hover, viewport, …)Trigger setup, cleanup, cancellation
loadModule / loadDataModule cache, data cache, error wrapping
<mountly-feature> markupCustom element wiring
  • Triggers — go deep on each trigger type.
  • Lifecycle — the state diagram and abort behaviour.
  • Caching — cache keys, invalidation, in-flight dedup.
  • Distribution — self-contained vs shared React.