What Alpine is for
Alpine.js handles UI state that lives entirely in the browser — open/closed dropdowns, modals, active tabs, accordions — the interactions that don't need a server round-trip.
Reach for Alpine when a click should change what's on screen without fetching anything. Reach for htmx when the click needs HTML from the server: a fragment swap, a form submit, or an SSE update.
| 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 |
Chirp ships the Alpine wiring you turn on with one config flag. The Alpine
script is injected for you, and a small set of template macros (dropdown,
modal, tabs) give you accessible components out of the box.
Enable it
SetAppConfig(alpine=True):
from chirp import App, AppConfig
config = AppConfig(alpine=True)
app = App(config=config)
That's the whole setup. Chirp injects the Alpine script (core plus plugins)
before</body>on every full-page HTML response, so your templates can use
Alpine attributes (x-data, x-show, @click) anywhere.
See AppConfig fields for the full option list.
Use the macros
Import Chirp's Alpine macros and call 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 %}
Each macro ships its own Alpine state and accessibility attributes:
| Macro | Signature | Notes |
|---|---|---|
dropdown |
dropdown(trigger="Menu", wrapper_class="", panel_class="") |
Toggle panel with click-outside and Escape; setsaria-expanded, aria-haspopup, role="menu". |
modal |
modal(id="chirp-modal", title="", wrapper_class="", content_class="", managed=true) |
Dialog with Escape to close; setsrole="dialog", aria-modal, aria-hidden. |
tabs |
tabs(tab_names, default=none, tab_list_class="", panel_class="") |
Tab list plus a panel slot; caller writesx-show="active === 'TabName'"per panel. |
modal defaults to managed=true (self-contained openstate). Set
managed=false to share a parent's openvariable so a sibling button controls
it. Add[x-cloak]{display:none!important}to your CSS so a modal stays hidden
until Alpine initializes.
Use it with htmx
Alpine 3 watches the DOM with a mutation observer, so when htmx swaps in HTML that contains Alpine attributes, Alpine initializes them automatically — no extra wiring.
A dropdown inside an htmx-loaded fragment works the same as one on the initial page:
<div id="user-card" hx-get="/users/1" hx-trigger="load" hx-swap="innerHTML">
Loading...
</div>
The server returns the fragment, and Alpine wires up the dropdown when it lands:
{% 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 %}
For a guided, end-to-end build mixing both, follow the Alpine + htmx tutorial.
Register your own components
When you register a named Alpine component with the standardAlpine.data(),
thealpine:initevent fires only once, on initial page load. Under htmx
boosted navigation, a swapped-in script that relies onalpine:initnever
re-registers — the component is dead after the first navigation.
UseAlpine.safeData(name, factory)instead. It is a drop-in replacement for
Alpine.data()that works on both initial loads and 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>
Pass server data to a component
When a component needs structured data from the server, emit it as a
<script type="application/json">tag and read it from JavaScript — quoting
JSON inside an HTML attribute is unsafe. Whenalpine=True, Chirp registers a
template global,alpine_json_config, so you don't hand-write the tag:
{{ 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>
The first argument is the id; the second is any JSON-serializable value (pass
None for JSON null). Non-serializable values fall back to default=str, the
same as Kida's| tojson filter. For small configs, {{ config | tojson(attr=true) }}
inside a double-quoted attribute works too; the script-tag pattern scales better
for large payloads.
Content-Security-Policy
Analpine=Trueapp runs under a strict nonce-only CSP out of the box — a
script-src without 'unsafe-inline'. Chirp builds the single inline Alpine
bootstrap per request and stamps it with the live CSP nonce, so it survives the
policy. You do not needalpine_csp=Truejust to satisfy a nonce policy.
For the dev-vs-prod severity rules behind thecsp_nonce and chirpui_csp
checks, see contract categories;
for production CSP wiring, see auth hardening.
Standalone CSP setup (nonce-only / eval-forbidding)
For a standalone app (no chirp-ui) on a strict nonce-only CSP:
- Enable a per-request nonce —
AppConfig(csp_nonce_enabled=True)auto-wiresCSPNonceMiddleware. This rebuilds every framework inline<script>per request and stamps it with the live nonce, so it survives a nonce-only policy. Without a nonce mechanism, a static inline-forbidding CSP blocks the scripts andapp.check()flags it via thecsp_noncecontract. - Keep
AppConfig(alpine=True)— noalpine_cspneeded for a nonce policy. - Allow the external Alpine script source (for example
https://cdn.jsdelivr.net). The plugin and core tags are externalsrc=scripts and need no nonce.
For aneval-forbidding policy, also set AppConfig(alpine_csp=True). The
@alpinejs/csp build evaluates x-data expressions without eval/Function,
so you can keep'unsafe-eval' out of your script-src.
How Alpine injection works
Chirp is the single authority for Alpine.js injection.AlpineInjectappends the
script block before the first</body>on:
- Buffered HTML responses — full pages and eligible buffered bodies.
- Streaming HTML —
StreamingResponsebodies (for exampleSuspense,Stream,TemplateStream) are rewritten chunk-by-chunk so Alpine appears in the final document without buffering the whole stream in memory. See streaming HTML responses.
Fragment responses (htmx partials) and other non-HTML responses pass through
unchanged. If Alpine is already present before</body>(detected via
data-chirp="alpine"), injection is skipped to prevent double-loading.
The injected block contains the Alpine core (jsDelivr CDN), the Mask,
Intersect, and Focus plugins, themodals and traysstores for
chirp-ui components, and theAlpine.safeData() helper. When use_chirp_ui(app)
is active, full-page HTML also includes the chirp-ui behavior runtime that
registers its named controllers (dropdown, copy, theme, dialog targets, shell
and sidebar behavior) through the same helper.