Integration
Summary
How Soma fits into a living application: what binds when, how to
keep components working through partial page updates, and the
handful of environment facts (frameworks, CSP, SSR) that save an
afternoon when known up front. Installation itself is on
Getting started; this page is
about everything after the first page load. A complete runnable
starter ships in the repository as
examples/app-shell.html: navbar, sidebar, page
header, content grid, footer, a wired dialog, and a live
Soma.scan() demo, consuming dist/
alone.
Load order and auto-init
Include the CSS once and the JS once. Any placement works, but
the conventional shape is CSS in <head>, JS
wherever your app loads scripts:
<link rel="stylesheet" href="/static/soma/soma.css" />
<script src="/static/soma/soma.umd.cjs" defer></script>
<!-- or, as a module: import Soma from '@nware/soma' -->
When the script runs, it exposes window.Soma
(UMD) or the default export (ESM) and schedules
auto-init: on DOMContentLoaded
(or immediately, if the document is already parsed), every
component discovered by its data-*/class hook is
bound. The full selector table is on
Getting started. Two rules
follow from this timing:
- Configure
Soma.i18nbefore auto-init runs (a classic script before the bundle, or first in your module graph): strings rendered into component DOM are baked at construction. - Markup present at load needs no page JS at all; only imperative components (dialogs, toasts, select, palette …) need a line of wiring. The overview matrix lists which is which.
Partial updates: Soma.scan()
Auto-init runs once. When your app replaces server-rendered markup afterwards (an ajax fragment, an htmx/Turbo swap, a portal plugin injecting a panel), bind the new content with one call:
const slot = document.getElementById('panel-slot');
slot.innerHTML = fragmentFromServer; // server-rendered soma-* markup
Soma.scan(slot); // bind everything inside it
Soma.scan(root) runs every component's declarative
auto-init scoped to the subtree. It is
idempotent: components are
singleton-per-element, so elements that are already bound are
skipped, and scanning too broadly is harmless.
Soma.scan() with no argument scans the whole
document (that is literally what load-time auto-init calls).
It scans the root's descendants: pass the swapped
container (or any ancestor), not the component element itself.
The other half of the lifecycle is teardown. Every element-bound
component has .destroy(). It unbinds listeners,
closes any open overlay, strips the attributes the component
generated, and drops the instance so the same element (or its
replacement) can be bound again. Destroy before you discard a
fragment whose components hold document-level state (open
overlays, document listeners); for plain content swaps where
the old DOM is simply garbage-collected, skipping destroy is
usually fine.
// The full swap lifecycle, explicit form:
Soma.datePicker('#panel-date').destroy(); // release the old fragment's instances
slot.innerHTML = fragmentFromServer; // swap
Soma.scan(slot); // bind the new one
Frameworks
Soma is vanilla and framework-agnostic; the integration point is always the same pair — bind after render, destroy on cleanup.
With htmx or Turbo, one global hook covers the whole app:
// htmx
htmx.on('htmx:afterSwap', (e) => Soma.scan(e.target));
// Turbo
document.addEventListener('turbo:load', () => Soma.scan());
document.addEventListener('turbo:frame-load', (e) => Soma.scan(e.target));
With React (or Preact), bind in an effect and
destroy in its cleanup; the same shape works for Vue's
onMounted/onUnmounted:
function DatePickerField({ id }) {
const ref = useRef(null);
useEffect(() => {
const dp = Soma.datePicker(ref.current);
return () => dp.destroy();
}, []);
return <input ref={ref} id={id} className="soma-input" />;
}
Let exactly one side own each element: if a framework re-renders markup from state, destroy before the re-render replaces the node (the effect cleanup above does this), or key the element so the framework replaces rather than mutates it.
Content Security Policy
Soma needs no unsafe-inline, no
unsafe-eval, and injects no script. One directive
matters: the icon set is delivered as CSS
mask-image data URIs, and CSS-loaded images fall
under img-src, so a strict policy needs
data: there or every glyph silently disappears:
<!-- minimal Soma-compatible policy -->
Content-Security-Policy: default-src 'self'; img-src 'self' data:
Components position overlays via the style
property (never style attributes in
injected HTML), which CSP does not restrict.
Server-side rendering
Soma is designed markup-first: every state class
(soma-active, aria-expanded,
validation states) is server-renderable, so pages arrive
correct before any JS runs. Two practical notes:
- Server-side templates sometimes bake stale inline styles
into overlay markup (
display: none; top: …from a previous render). Dropdown menus wipe these at bind, but prefer emitting clean overlay markup and letting thearia-hiddenCSS own visibility. - Rendering user data into
soma-*markup is normal HTML templating: escape it exactly as you would anywhere else. The APIs that deliberately accept raw HTML are listed in the accessibility checklist.
See also
- Getting started — install routes, the auto-init selector table, your first page.
- CDN — hosted flatpack paths, version pinning, the downloadable zip.
examples/app-shell.html(repository) — the runnable starter this page describes.- llms.txt — the machine-readable contract, for AI coding agents implementing Soma.