Skip to main content

Embed as a UI island

You don't need a full SPA shell to use semantic-ui. Drop a single <div> into any existing page — rendered by your CMS, framework, or template engine — and mount one UiNode tree into it. The host page's layout is untouched.

An island has two halves, and both matter: the renderer paints the tree, and the event bus makes it respond. Mount without a bus and every button, row action and form submit in that tree is inert — you have rendered a picture of a UI. Unless the island really is read-only, you want both.

Minimal example

<link rel="stylesheet" href="/sui/sui.css">

<h1>My existing page</h1>
<p>Content rendered by whatever already builds this page.</p>

<div id="product-table"></div>

<script type="module">
import { SuiRenderer, installDefaultHandlers } from "/sui/renderer.js";
import { SuiEventBus } from "/sui/eventbus.js";

const host = document.getElementById("product-table");
const renderer = installDefaultHandlers(new SuiRenderer(host));

// The bus listens on the host element, so everything it does stays
// inside this <div>. History off: the address bar belongs to the host page.
const bus = new SuiEventBus(renderer, host).setHistoryEnabled(false);

const node = await fetch("/api/products", {
headers: { Accept: "application/json" }
}).then(r => r.json());

renderer.mount(node);
</script>

Now data-trigger elements inside the island fetch and apply updates just like in a full SPA, but scoped to your <div>.

Order doesn't matter

You can create the bus before or after mount(). It enhances whatever is already inside the host element and watches for later changes with a MutationObserver, so tab overflow, menu state and popovers get wired up either way — including on SSR-rendered markup that was already in the page.

An island endpoint returns a node, not a UiPage

renderer.mount() renders a single node — there is no page handler in the renderer, so mounting a UiPage envelope would just dump its JSON. Have the island endpoint return the bare node (a table, form, detail, …), or unwrap it yourself with renderer.mount(page.node). Full-page apps don't hit this: the event bus unwraps the envelope for you.

That's it — the <div> becomes a live UiNode island. Use as many islands on a page as you want; each gets its own SuiRenderer and its own bus, bound to its own host element.

The /sui/* URLs above assume a backend that serves the runtime (any Spring Boot app with mc-semantic-ui-core on the classpath). If the host page has no such backend, load the files from the public docs site instead — see CDN assets for HTML clients.

Island vs. full app

UI islandFull SPA app
Host pageany existing pagea static index.html shell you own
What rendersone tree into one <div>every screen, full-page
Event busneeded as soon as anything is clickable; history offyes — clicks, forms, navigation, history
Use whenadding a widget to an existing sitebuilding the whole UI with semantic-ui

What the bus gives the island

The bus is the same one the full app uses; in an island you simply configure it more defensively.

Handle triggers locally, with no backend

An island often lives on a page whose backend knows nothing about semantic-ui. A client handler answers a trigger in the browser — return a UiPatch (or a UiPage) and the bus applies it for you:

bus.registerClientHandler("addToCart", (ctx) => {
cart.push(ctx.payload); // your own state
return { // a UiPatch, applied by the bus
patches: [{ op: "REPLACE", targetId: "cart-badge",
node: { type: "text", id: "cart-badge",
text: `${cart.length} items` } }],
toasts: [{ level: "SUCCESS", message: "Added." }]
};
});

The matching trigger is UiTrigger.invoke("addToCart") — the same node your server would emit. Handlers may be async.

Patch from the outside

The host page can drive the island directly, which is the usual way to react to something that happens elsewhere on the page:

bus.applyPatch({ patches: [{ op: "REPLACE", targetId: "product-list", node: tree }] });

Keep failures inside the island

By default a failed fetch surfaces as a toast. On a host page you don't own, route errors into your own UI instead:

bus.setOnError((err) => myHostPageBanner(
err.kind === "http"
? `Products unavailable (${err.response.status})`
: `Could not reach ${err.url}`));

Also useful here: setFetcher() to reuse the host page's authenticated fetch, and setUrlRewriter() when the island's API lives under a different prefix.

Turn history off in an island

By default the bus pushes every navigation into history.pushState — correct for a full-page app, wrong for a widget embedded in someone else's page (it would hijack the host's URL and its back button). setHistoryEnabled(false) keeps the island self-contained. With several islands on one page, give each its own bus and turn history off on all of them.