Alpine.js

Local UI state with Alpine.js — dropdowns, modals, tabs

5 min read 960 words

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 HTMLStreamingResponse bodies (for example Suspense, 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:modals and traysstores 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(trigger, wrapper_class="", panel_class="")— Toggle panel with click-outside and Escape key
  • Accessible:aria-expanded, aria-haspopup, role="menu"
  • modal(id, title="", wrapper_class="", content_class="", managed=true)— Dialog with Escape to close
  • managed=true (default): self-contained. managed=false: use parent's openvariable 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 withx-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:

  1. SetAppConfig(alpine_csp=True)
  2. Ensure your CSP allows the Alpine script source (e.g.https://unpkg.com)
  3. If usingeval()-based policies, Alpine's CSP build avoids eval