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.i18n before 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 the aria-hidden CSS 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.