Skip to content

Custom element

Some hosts drop in HTML rather than write JS: CMS pages, marketing sites, partner integrations. For them, declarative markup is easier to maintain than imperative code. mountly ships a custom element for that.

<signup-card trigger="viewport" props='{"plan":"pro"}'></signup-card>
<script type="module">
import { defineMountlyFeature } from "mountly/elements";
defineMountlyFeature("/widgets/dist/index.js");
</script>

That’s the common path. Mountly scans the page, defines <signup-card>, registers it as a feature, and loads it from the shared source. The element handles attach, mount, prop updates, and unmount on disconnect for you.

If all widgets live in one bundle, pass that bundle once:

defineMountlyFeature("/widgets/dist/index.js");

If the host page needs namespaced tags, add a prefix:

<acme-signup-card trigger="viewport" props='{"plan":"pro"}'></acme-signup-card>
defineMountlyFeature({
source: "/widgets/dist/index.js",
prefix: "acme",
});

The prefix only affects the browser tag. Mountly still uses signup-card as the module ID and signupCard as the named export in a shared bundle.

For per-widget bundles, give Mountly a base URL and limit the allowed modules:

defineMountlyFeature({
baseUrl: "/widgets",
modules: ["signup-card", "payment-breakdown"],
});

That registers only those modules and resolves /widgets/signup-card/dist/index.js, /widgets/payment-breakdown/dist/index.js, and so on. You save bytes when components split into separate bundles.

You can also stay explicit:

defineMountlyFeature({
modules: {
"signup-card": "/widgets/signup-card.js",
"payment-breakdown": "/widgets/payment-breakdown.js",
},
});

The lower-level registerCustomElement() API is still available when a feature needs custom loading, data, or analytics wiring.

Attribute Type Default Notes
module-id string inferred on alias tags Must match a registered feature. Alias tags use their tag name.
trigger "hover" · "click" · "focus" · "viewport" · "idle" · "media" · "url-change" "click" High-level trigger preset.
preload-on "hover" · "viewport" · "idle" · "media" none Opt-in prefetch. Never implied by trigger.
activate-on "click" · "hover" · "focus" · "viewport" · "idle" · "media" · "url-change" trigger Activation trigger when it differs from trigger.
preload-media-query string Required with preload-on="media".
activate-media-query string Required with activate-on="media" or trigger="media".
idle-timeout number ms Used by idle triggers.
viewport-root-margin string "0px" Forwarded to IntersectionObserver.
data-url string If set, the element fetches JSON from this URL as loadData.
data-method "GET" · "POST" etc "GET" Used with data-url.
props JSON string {} Passed to render(). Live-updates via update().
mount-selector CSS selector own [data-mountly-mount] slot Render target. Must match a descendant of the element.

Custom element alias tags must contain a hyphen because the browser requires it. If your internal module ID is signup, use <mountly-feature module-id="signup"> or map a valid alias:

defineMountlyFeature({
modules: { signup: "/widgets/signup.js" },
aliases: { "signup-card": "signup" },
});

Change the props attribute and the element calls feature.update(...). If the widget exposes update(container, props) (React adapter does, others can opt in), framework-internal state is preserved. Otherwise it falls back to a re-render in the same container.

const el = document.querySelector("mountly-feature");
el.setAttribute("props", JSON.stringify({ plan: "enterprise" }));

The element renders into a <div data-mountly-mount> it appends to itself. Pass mount-selector to render into a container you control instead — it has to be a descendant of the element, so the slot stays inside the element’s own subtree:

<mountly-feature module-id="payment-breakdown" trigger="click" mount-selector=".panel">
<button>Buy</button>
<div class="panel"></div>
</mountly-feature>

A selector matching nothing inside the element falls back to the default slot. To mount into a container elsewhere on the page, use the core’s data-target, which does fall back to a document-wide lookup.

If the element connects with a module-id that has no registered factory, you get a console warning listing the registered IDs. The element stays inert until you register the factory later, which helps when the registration is split across code-split chunks.

  • You need onMount / onError callbacks.
  • The trigger element is dynamic (e.g. created in response to user input).
  • You want to wire analytics next to the trigger.

In those cases, call feature.attach({ trigger, mount, … }) directly. The custom element is the ergonomic shortcut for static-HTML hosts; attach() is the full API.

You can replicate Astro timing directives in declarative HTML:

  • client:idletrigger="idle" (optionally idle-timeout)
  • client:visible={{ rootMargin }}trigger="viewport" + viewport-root-margin="..."
  • client:mediatrigger="media" + activate-media-query="(query)"

Hover has no delay attribute; it uses the core’s 100 ms settle. For a different delay, use the core directly with data-on="hover:300".

Compiler/server directives remain Astro-only (client:only, server:defer, set:html, etc.).