Islands are lightweight JavaScript adapters that add client-side state to server-rendered HTML. They are not a framework — each island is a small, self-contained module that communicates through DOM events. No build step required.
When to use islands vs Alpine.js
| Reach for | When |
|---|---|
| Alpine.js | Toggle visibility, simple UI state,x-show/x-bindpatterns |
| Islands | Persistent state (localStorage, URL params), multi-step flows, file uploads, error boundaries |
| Fragment islands | Isolating HTMX mutation regions from inherited shell attributes |
Alpine handles ephemeral UI state. Islands handle durable state that survives navigation or needs coordination across components.
Architecture
Foundation API
Every island primitive imports fromfoundation.js, which provides the core lifecycle:
| Function | Purpose |
|---|---|
readProps(payload) |
Extract props object from the island payload |
attachCleanup(payload, fn) |
Register a cleanup function (called on unmount) |
runCleanup(payload) |
Execute and remove the cleanup function |
setState(payload, api, state) |
Emit achirp:island:stateevent |
setAction(payload, api, action, status, extra) |
Emit achirp:island:actionevent |
setError(payload, api, reason, extra) |
Emit achirp:island:errorevent |
registerPrimitive(name, adapter) |
Register an adapter withwindow.chirpIslands |
Each emit function checks for a Chirp-providedapi object first (with emitState/emitAction/emitError methods). If no API is available, it falls back to dispatching CustomEvent on both document and window.
Adapter contract
Every island primitive exports a single adapter object with amountmethod:
import { readProps, setState, registerPrimitive } from "./foundation.js";
registerPrimitive("my_primitive", {
mount(payload, api) {
const props = readProps(payload);
const root = payload.element;
// Set up DOM listeners, read initial state...
setState(payload, api, { value: props.initial });
// Return cleanup function
return () => {
// Remove listeners, clear timers
};
}
});
The payloadobject contains:
| Field | Type | Description |
|---|---|---|
element |
HTMLElement |
The root<section>element |
name |
string |
Primitive name |
id |
string |
Unique mount ID |
version |
string |
Primitive version |
props |
object |
Props passed from the template macro |
Event protocol
Islands communicate through six lifecycle events, all dispatched on bothdocument and window:
| Event | Detail fields | When |
|---|---|---|
chirp:island:mount |
name, id, version |
Island mounts for the first time |
chirp:island:unmount |
name, id |
Island is destroyed |
chirp:island:remount |
name, id |
Island re-mounts after an HTMX swap |
chirp:island:state |
name, id, version, state |
State changes |
chirp:island:action |
name, id, version, action, status, ...extra |
Action triggered |
chirp:island:error |
name, id, version, error, reason, ...extra |
Error occurs |
Listen globally:
document.addEventListener("chirp:island:state", (e) => {
const { name, id, state } = e.detail;
console.log(`${name}#${id}:`, state);
});
Built-in primitives
chirp-ui ships seven ready-to-use primitives, each with a corresponding Kida macro instate_primitives.html:
| Primitive | Macro | Purpose | Key data attributes |
|---|---|---|---|
state_sync |
state_sync(state_key) |
Sync inputs with URL query params | data-state-field |
action_queue |
action_queue(action_id) |
Button-triggered action with status | data-action-trigger, data-action-status |
draft_store |
draft_store(draft_key) |
Auto-save form fields to localStorage | data-draft-field, data-draft-saved-at |
error_boundary |
error_boundary(boundary_id) |
Catch errors and show fallback UI | data-error-body, data-error-fallback, data-error-reset |
grid_state |
grid_state(state_key, columns) |
Filter, sort, select table rows | data-grid-filter, data-grid-row, data-grid-select, data-grid-sort |
wizard_state |
wizard_state(state_key, steps) |
Multi-step wizard navigation | data-wizard-step, data-wizard-next, data-wizard-prev, data-wizard-status |
upload_state |
upload_state(state_key, endpoint) |
File upload with progress | data-upload-input, data-upload-start, data-upload-progress, data-upload-status |
Usage
Import the macro and wrap your content:
{% from "chirpui/state_primitives.html" import state_sync %}
{% call state_sync("search", query_param="q") %}
<input type="text" data-state-field placeholder="Search..." />
{% end %}
The macro handles loading the JavaScript, mounting the island, and wiring up the data attributes. All you provide is the HTML inside the slot.
Draft store example
{% from "chirpui/state_primitives.html" import draft_store %}
{% call draft_store("new-post") %}
<input name="title" data-draft-field placeholder="Title" />
<textarea name="body" data-draft-field placeholder="Body..."></textarea>
<small data-draft-saved-at></small>
{% end %}
Fields with data-draft-field are auto-saved to localStorage with a 250ms debounce. The data-draft-saved-atelement displays the last save timestamp. Values are restored on mount.
Wizard example
{% from "chirpui/state_primitives.html" import wizard_state %}
{% call wizard_state("onboarding", steps=3) %}
<div data-wizard-step>Step 1: Account details</div>
<div data-wizard-step>Step 2: Preferences</div>
<div data-wizard-step>Step 3: Confirmation</div>
<button data-wizard-prev>Back</button>
<button data-wizard-next>Next</button>
<span data-wizard-status></span>
{% end %}
Only the active step is visible. Navigation buttons disable at boundaries. The status element shows "Step 1 of 3".
Fragment islands
Fragment islands solve a different problem: HTMX attribute inheritance. In an app shell withhx-boost or hx-select, child forms inherit those attributes, causing unexpected behavior. Fragment islands isolate a region by applying hx-disinherit.
fragment_island
{% from "chirpui/fragment_island.html" import fragment_island %}
{% call fragment_island("step-list") %}
{% for step in steps %}
<div>{{ step.name }}</div>
{% end %}
{% end %}
Renders a <div> with hx-disinherit="hx-select hx-target hx-swap", preventing inherited HTMX attributes from leaking into the region.
fragment_island_with_result
Adds a mutation result container for form feedback:
{% from "chirpui/fragment_island.html" import fragment_island_with_result %}
{% call fragment_island_with_result("items", "item-result") %}
<form hx-post="/items" hx-target="#item-result" hx-swap="innerHTML">
<input name="title" />
<button type="submit">Add</button>
</form>
{% end %}
The result <div> (with aria-live="polite") sits inside the fragment, guaranteeing the target exists in the same DOM subtree.
poll_trigger
A hidden button that fires on page load, useful for deferred content:
{% from "chirpui/fragment_island.html" import poll_trigger %}
{{ poll_trigger("/dashboard/stats", "#stats-panel", delay="2s") }}
| Parameter | Default | Description |
|---|---|---|
url |
required | Endpoint to poll |
target |
required | CSS selector for the swap target |
delay |
none |
Delay before first trigger (e.g."2s") |
swap |
"innerHTML" |
HTMX swap strategy |
select |
none |
Optionalhx-select |
Writing a custom primitive
- Create a JS file in your static directory (e.g.
/static/islands/my_counter.js):
import {
readProps,
setState,
setAction,
registerPrimitive,
} from "./foundation.js";
registerPrimitive("my_counter", {
mount(payload, api) {
const { initial = 0 } = readProps(payload);
const root = payload.element;
let count = initial;
const display = root.querySelector("[data-counter-value]");
const plusBtn = root.querySelector("[data-counter-plus]");
const minusBtn = root.querySelector("[data-counter-minus]");
function update() {
if (display) display.textContent = count;
setState(payload, api, { count });
}
function onPlus() {
count++;
update();
setAction(payload, api, "increment", "success");
}
function onMinus() {
count--;
update();
setAction(payload, api, "decrement", "success");
}
plusBtn?.addEventListener("click", onPlus);
minusBtn?.addEventListener("click", onMinus);
update();
return () => {
plusBtn?.removeEventListener("click", onPlus);
minusBtn?.removeEventListener("click", onMinus);
};
},
});
- Use
island_rootdirectly in a template:
{% from "chirpui/islands.html" import island_root %}
{% call island_root(
"my_counter",
props={"initial": 0},
src="/static/islands/my_counter.js",
primitive="my_counter"
) %}
<span data-counter-value>0</span>
<button data-counter-minus>-</button>
<button data-counter-plus>+</button>
{% end %}
Or wrap it in a macro in your own template file for reuse, following the same pattern as the built-in state_primitives.htmlmacros.