UI Islands¶
Islands are self-contained, interactive JavaScript components that mount into server-rendered pages. They let you add client-side interactivity — charts, editors, drag-and-drop — without coupling the entire app to a JavaScript framework.
Overview¶
Dazzle apps are server-rendered with htmx. Most interactions work without JavaScript. But some features (data visualizations, rich editors, real-time updates) need client-side code. Islands solve this by providing designated mount points where JavaScript components take over a specific DOM subtree.
Each island:
- Owns its DOM subtree
- Manages its own state
- Communicates with the server through a defined API contract
- Cleans up when removed from the page (including htmx swaps)
DSL Syntax¶
island <name> "<Title>":
entity: <EntityName>
src: "<path/to/island.js>"
fallback: "<HTML shown before JS loads>"
prop <name>: <type> [= default]
event <name>:
detail: [field1, field2]
All fields except name are optional.
Fields¶
| Field | Required | Description |
|---|---|---|
name |
Yes | Unique identifier. Used for mount points and API routes. |
title |
No | Human-readable display name |
entity |
No | Entity reference. Generates a data API at /api/islands/{name}/data |
src |
No | JavaScript entry point. Defaults to /static/islands/{name}/index.js |
fallback |
No | HTML rendered server-side before JavaScript loads |
Props¶
Typed properties passed to the JavaScript component:
prop chart_type: str = "bar"
prop count: int = 10
prop enabled: bool = true
prop ratio: float = 3.14
prop label: str # no default
Supported types: str, int, bool, float. Props are serialized to JSON and passed to the mount function.
Events¶
Document the CustomEvents the island may emit:
Events are informational — they document the contract for consumers. The actual event emission is handled by the island's JavaScript code.
JavaScript Contract¶
Each island module must export a mount function:
// islands/my-component/index.js
export function mount({ el, props, apiBase }) {
// el: HTMLElement — the <div data-island="..."> mount point
// props: object — parsed props with defaults applied
// apiBase: string — base URL for API endpoints ("" if no entity)
// ... initialize component ...
// Optionally return a cleanup function
return function unmount(el) {
// cleanup timers, listeners, etc.
};
}
The cleanup function is called when:
- The island element is removed from the DOM
- htmx swaps content that contains the island
- The page navigates away
Generated HTML¶
The framework renders each island as:
<div data-island="task_chart"
data-island-src="/static/islands/task-chart/index.js"
data-island-props='{"chart_type":"bar","height":400}'
data-island-api-base="/api/islands/task_chart"
class="dz-island">
Loading task chart...
</div>
The island loader (dz-islands.js) handles:
- Mounting islands on page load
- Rescanning after htmx swaps (
htmx:afterSettle) - Unmounting before htmx replaces content (
htmx:beforeSwap) - Deduplication (prevents double-mounting)
API Routes¶
When an island declares an entity binding, the framework generates:
Response:
No route is created for islands without an entity binding.
Examples¶
Data-Bound Chart¶
entity Task "Task":
id: uuid pk
title: str(200) required
completed: bool = false
island task_chart "Task Progress":
entity: Task
src: "islands/task-chart/index.js"
fallback: "Loading chart..."
prop chart_type: str = "bar"
prop period: str = "week"
// islands/task-chart/index.js
export async function mount({ el, props, apiBase }) {
const response = await fetch(`${apiBase}/data`);
const data = await response.json();
renderChart(el, {
items: data.items,
type: props.chart_type,
period: props.period,
});
}
Simple Effect (No Entity)¶
island confetti "Celebration Confetti":
src: "islands/confetti/index.js"
fallback: "<p>Celebration!</p>"
export function mount({ el }) {
const button = document.createElement("button");
button.textContent = "Celebrate";
button.addEventListener("click", () => launchConfetti());
el.appendChild(button);
return () => button.remove();
}
Event-Emitting Island¶
island timeline_editor "Timeline Editor":
entity: Event
src: "islands/timeline/index.js"
event time_updated:
detail: [event_id, new_time]
event event_deleted:
detail: [event_id]
export function mount({ el, props, apiBase }) {
el.addEventListener("click", (e) => {
if (e.target.dataset.action === "delete") {
el.dispatchEvent(
new CustomEvent("event_deleted", {
detail: { event_id: e.target.dataset.id },
})
);
}
});
}
File Structure¶
Islands follow this layout:
static/islands/
my-component/
index.js # ES module with mount() export
styles.css # Scoped styles (optional)
Best Practices¶
- Return cleanup functions — clear timers, remove listeners, abort fetches
- Handle errors — wrap fetch calls and show fallback UI on failure
- Scope styles — use BEM or CSS modules to avoid global collisions
- Keep islands focused — one responsibility per island
- Use progressive enhancement — provide meaningful
fallbackcontent - Lazy load dependencies — use dynamic
import()for large libraries - Document events — declare all CustomEvents in the DSL for discoverability