Overview
Alpine.js complements htmx for client-only UI state: dropdowns, modals, tabs, accordions. htmx handles server round-trips; Alpine handles interactions that don't need a request.
Chirp integrates Alpine via config and macros. When enabled, the Alpine script is
auto-injected before</body>. If you also use chirp-ui, use_chirp_ui(app)
serves the shared static assets and injectschirpui-alpine.jsas the behavior
runtime alongside the core Alpine bootstrap.
When to Use Alpine vs htmx
| Use Alpine for | Use htmx for |
|---|---|
| Dropdowns, modals, tabs | Form submissions, partial swaps |
| Toggles, accordions | Search-as-you-type |
| Local validation before submit | SSE live updates |
| Client-only state | Server-driven content |
Enabling Alpine
If you usechirp-ui, Alpine is enabled automatically:
from chirp import App, use_chirp_ui
app = App()
use_chirp_ui(app) # auto-enables alpine=True
For apps without chirp-ui, enable it explicitly:
from chirp import App, AppConfig
config = AppConfig(alpine=True)
app = App(config=config)
Chirp is the single authority for Alpine.js injection. AlpineInject appends the script before the first </body> on:
- Buffered HTML responses — full pages and eligible buffered bodies (see middleware: fragment gating below).
- Streaming HTML —
StreamingResponsebodies (for exampleSuspense,Stream,TemplateStream) are rewritten chunk-by-chunk so Alpine appears in the final document without buffering the entire stream in memory.
Fragment responses (htmx partials) and other non-HTML responses are unchanged. If Alpine is already present before</body> (detected via data-chirp="alpine"), injection is skipped to prevent double-loading.
The injection block includes:
- Alpine core (jsdelivr CDN)
- Plugins: Mask, Intersect, Focus
- Store init:
modalsandtraysstores for chirp-ui components Alpine.safeData()helper for htmx-safe component registration
Whenuse_chirp_ui(app)is active, full-page HTML also includes the
chirpui-alpine.jsruntime that registers chirp-ui's named Alpine controllers,
including streamed full-page HTML responses.
Passing server data (alpine_json_config)
When Alpine components need structured data from the server, put JSON in a
<script type="application/json">tag and read it from JavaScript (see Kida’s
escaping docs for why raw| tojsoninside double-quoted attributes is unsafe).
Chirp registers a template global alpine_json_config when alpine=True
so you do not have to hand-write the script tag. The first argument is the
idattribute (a string); the second is any JSON-serializable value (use
None for JSON null). Non-JSON-serializable objects use default=str, same
as Kida’s| tojsonfilter.
{{ alpine_json_config("game-config", game_config) }}
<div x-data="matchGame()">...</div>
<script>
var cfg = JSON.parse(document.getElementById("game-config").textContent);
Alpine.safeData("matchGame", function() {
return { rows: cfg.rows, cols: cfg.cols };
});
</script>
For small configs you can instead use {{ config | tojson(attr=true) }}inside a
double-quoted attribute (see Kida filter reference). The script-tag pattern scales
better for large payloads and matches Django’sjson_scriptstyle.
Configuration Options
| Field | Type | Default | Description |
|---|---|---|---|
alpine |
bool |
False |
Enable Alpine.js script injection (use_chirp_ui sets this to Trueautomatically) |
alpine_version |
str |
"3.15.8" |
Pinned Alpine version (jsdelivr CDN) |
alpine_csp |
bool |
False |
Use CSP-safe build for strict Content-Security-Policy |
For strict CSP, setalpine_csp=Trueand ensure your CSP allows the Alpine script.
Using the Macros
Import Chirp's Alpine macros and use them in your templates:
{% from "chirp/alpine.html" import dropdown, modal, tabs %}
{% call dropdown("Menu") %}
<a href="/a">Link A</a>
<a href="/b">Link B</a>
{% end %}
{% call modal("confirm-dialog", title="Confirm") %}
<p>Are you sure?</p>
<button @click="open = false">Yes</button>
<button @click="open = false">Cancel</button>
{% end %}
{% call tabs(["Overview", "Details"], "Overview") %}
<div x-show="active === 'Overview'">Overview content</div>
<div x-show="active === 'Details'">Details content</div>
{% end %}
Dropdown
dropdown(trigger, wrapper_class="", panel_class="")— Toggle panel with click-outside and Escape key- Accessible:
aria-expanded,aria-haspopup,role="menu"
Modal
modal(id, title="", wrapper_class="", content_class="", managed=true)— Dialog with Escape to closemanaged=true(default): self-contained.managed=false: use parent'sopenvariable so a sibling button can control it- Add
[x-cloak]{display:none!important}to your CSS so the modal stays hidden until Alpine initializes - Accessible:
role="dialog",aria-modal,aria-hidden
Tabs
tabs(tab_names, default=none, tab_list_class="", panel_class="")— Tab list + panel slot- Caller provides panel content with
x-show="active === 'TabName'"per panel
Registering Custom Components (Alpine.safeData)
When you register named Alpine components withAlpine.data(), the standard alpine:init event only fires once on the initial page load. Under htmx boosted navigation, swapped-in scripts that rely on alpine:initwill not re-register.
Chirp providesAlpine.safeData(name, factory)— a drop-in replacement for
Alpine.data()that works on both initial loads and htmx-boosted navigations:
<script>
Alpine.safeData("counter", () => ({
count: 0,
increment() { this.count++; },
}));
</script>
<div x-data="counter">
<span x-text="count"></span>
<button @click="increment">+</button>
</div>
Why not Alpine.data() directly? On the first page load, Alpine.data()
must be called during or before thealpine:initevent — but after Alpine is
loaded. On subsequent htmx navigations, Alpine is already initialized so
Alpine.data() works immediately. Alpine.safeData()handles both cases: it
queues registrations until Alpine is ready, then becomes a direct passthrough.
This is also the preferred registration path for shared chirp-ui behavior. The
runtime shipped byuse_chirp_ui(app)registers named controllers such as
dropdown, copy, theme/style, dialog-target, and shell/sidebar behavior via the
same helper.
htmx + Alpine Together
Alpine 3 uses a mutation observer to discover new elements. When htmx swaps in HTML that contains Alpine attributes (x-data, x-show), Alpine initializes them automatically. No extra wiring needed.
Example: a dropdown inside an htmx-swapped fragment:
<div id="user-card" hx-get="/users/1" hx-trigger="load" hx-swap="innerHTML">
Loading...
</div>
The server returns:
{% from "chirp/alpine.html" import dropdown %}
{% call dropdown("Actions") %}
<a href="/users/1/edit">Edit</a>
<button hx-delete="/users/1" hx-target="#user-card">Delete</button>
{% end %}
Alpine initializes the dropdown when the fragment is swapped in.
CSP Setup
For strict Content-Security-Policy:
- Set
AppConfig(alpine_csp=True) - Ensure your CSP allows the Alpine script source (e.g.
https://unpkg.com) - If using
eval()-based policies, Alpine's CSP build avoidseval