Islands

Mount client-owned JavaScript surfaces with no VDOM, no build step, and zero per-client server state.

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

What islands are

An island is an explicit escape hatch for the rare surface that genuinely needs client-resident state — a rich text editor, a drag-to-reorder list, a toggle that must paint before the server answers. Adata-islandelement marks where your JavaScript adapter mounts and owns the DOM. There is no VDOM, no build step, and zero per-client server view state: the server keeps returning authoritative HTML and remembers nothing about any one client.

For the most common case — paint a mutation instantly, let htmx do the real request, reconcile by swapping the server's answer — Chirp ships one blessed primitive that works out of the box:optimistic_apply. Most readers need only that. Everything else is a framework-agnostic mount contract you bring your own adapter to.

New in 0.8

Islands are deliberately rare. Chirp stays server-first by default — routing, shell composition, fragments and OOB updates, and most mutations stay server-owned.

When to reach for an island

Pick the lightest tool that expresses the interaction. An island is the heaviest option here, so most of the time you want one of the others.

You need Reach for
Client-owned DOM (editor, drag-reorder, a widget that manages itself) Adata-islandisland
Instant feedback on a mutation, reconciled by the server's answer optimistic_apply(a blessed island primitive — see below)
A region with its ownhx-target / hx-swapdefaults and swap safety fragment_island(app shells and OOB regions) — not an island
Realtime push after the page loads EventStream / SSE

fragment_islandlooks similar in prose but is a different thing in code: it is an HTMX swap-safety boundary that isolates a region from inheritedhx-* behavior. It runs no client-side JavaScript and mounts no adapter. Use it for swap safety; use adata-islandisland for client-managed DOM.

Minimal working example:optimistic_apply

optimistic_applyis the one primitive Chirp ships the runtime for, so it works with a contract guarantee out of the box. It paints a mutation locally from the client's own pre-mutation snapshot, lets htmx do the real request, swaps in the authoritative server fragment on success (last-write-wins), and reverts to the snapshot only when no authoritative fragment lands.

Put the htmx trigger andoptimistic_attrs(...)on the same element:

<button
    id="like-btn"
    hx-post="/toggle-like"
    hx-target="#like-btn"
    hx-swap="outerHTML"
    {{ optimistic_attrs([
         {"op": "toggleClass", "value": "liked"},
         {"op": "setText", "expr": "+1", "sel": ".like-count"},
         {"op": "disable"},
       ], mount_id="like-btn") }}>
  <span class="like-count">{{ count }}</span> Like
</button>

The handler is an ordinary mutation that returns the authoritative fragment. It is never told an optimistic apply happened — the return type is the intent, nothing more:

@app.route("/")
def index():
    return Template("index.html", **_like_context())


@app.route("/toggle-like", methods=["POST"])
def toggle_like():
    """Ordinary mutation. Returns ONLY the authoritative fragment; the optimistic
    paint is confirmed when htmx swaps it in."""
    STATE["liked"] = not STATE["liked"]
    STATE["count"] += 1 if STATE["liked"] else -1
    return Fragment("index.html", "like_button", **_like_context())

Source: examples/standalone/optimistic_apply/app.py.

That is the whole loop. The button paints instantly; htmx posts; the like_buttonfragment swaps in and confirms the paint. The full runnable demo (a confirmed Like and a reverting failure) lives in examples/standalone/optimistic_apply/.

Common patterns

Mount a custom island

Any element withdata-island is a mount root. Use island_attrs(...)rather than hand-writing the JSON attributes:

<div{{ island_attrs("editor", props=state, mount_id="editor-root",
                    src="/static/editor.js") }}>
  <p>Fallback editor UI for no-JS mode.</p>
</div>

That renders the mount-root attributes your adapter reads:

Attribute Role
data-island(required) Logical island name your client adapter registers against.
data-island-version Contract/runtime version (default"1").
data-island-props JSON payload for initial state. Must be JSON-serializable.
data-island-src Adapter/runtime hint for lazy loaders. Neverjavascript:.
id Stable mount id for deterministic remount targeting.

Always ship fallback markup

Place useful markup inside the mount root. If the island runtime fails to load or mount, the server-rendered fallback stays visible and functional. That fallback is the no-JS experience.

Turn the runtime on

from chirp import App, AppConfig

app = App(
    AppConfig(
        islands=True,
        islands_version="1",
    )
)

With islands=True, Chirp injects a small runtime that scans for [data-island] on load, unmounts islands before htmx swaps, and mounts or remounts them after.

Gotchas

Next step

Copy the runnableoptimistic_applydemo — a confirmed Like and a reverting failure — fromexamples/standalone/optimistic_apply/, wire it onto your own mutation, then read the reactive system to keep server-managed regions from re-rendering over client-owned DOM.

Advanced