Alpine.js

Local UI state with Alpine.js — dropdowns, modals, tabs — alongside htmx for server round-trips

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

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.