Overview
Chirp lets you build stateful client widgets without React, Vite, or an npm
build step. Server-rendered HTML and htmx fragments
handle the data; small ES-module islands
— self-mounting widgets in/static/islands/*.js— handle client-only state
such as drag reorder, multi-step wizards, and optimistic toggles.
Reach for this when a widget needs richer client behavior thanhx-*attributes
give you, but you do not want a front-end framework or its build pipeline. For
something lighter than an island, Alpine.js
covers small interactivity inline in the template.
Recommended stack
- Server rendering: Chirp + Kida templates
- Partial updates: htmx
- Stateful widgets: islands with
/static/islands/*.jsES modules
Reach for a primitive first
Chirp ships named primitives for the client-state shapes you hit most. Use one of these before reaching for a full framework island:
state_sync, action_queue, draft_store, error_boundary, grid_state,
wizard_state, upload_state, optimistic_apply.
You mount a primitive withprimitive_attrs(...), which emits the
data-island-primitivemetadata the runtime reads:
<section{{ primitive_attrs("wizard_state", props={"stateKey": "signup", "steps": ["a", "b", "c"]}) }}>
...
</section>
Optimistic UI without server state
optimistic_applyis the one primitive whose client runtime Chirp ships, so you
mount it without writing any JavaScript. It paints a mutation instantly from the
client's own snapshot, lets htmx do the real request, swaps the authoritative
server fragment on success, and reverts on failure. The server keeps zero
per-client view state: the handler is identical with or without the adapter.
<button hx-post="/toggle-like" hx-target="#like-btn" hx-swap="outerHTML"
{{ optimistic_attrs([{"op": "toggleClass", "value": "liked"},
{"op": "setText", "expr": "+1", "sel": ".count"},
{"op": "disable"}], mount_id="like-btn") }}>...</button>
It closes ~80% of the optimistic-UI gap (one in-flight mutation per region, last-write-wins, replacing swaps only). For concurrent collaborative editing, reach for a framework island. Full op contract and guardrail: Islands.
Decision rule
Choose a primitive when:
- state is local to one widget
- htmx still handles the server data boundaries
- you do not need a full client router or runtime
Choose a framework island when:
- third-party JS libraries force framework lifecycle APIs
- component complexity becomes a mini-app with deep client-only state
For server-driven realtime (push from the server after the page loads), use signals and SSE instead — see the reactive system.
Island mount checklist
Once you pick an island, wire these every time:
- include SSR fallback content in every mount root
- always set a stable mount
id - set
data-island-versionexplicitly - prefer
primitive_attrs(...)over raw attributes to keep the props schema clear - keep runtime diagnostics enabled — the runtime emits a
chirp:island:errorevent you can listen for