Overview
Chirp's fragment rendering makes htmx integration seamless. These patterns demonstrate common interactive UI features with zero client-side JavaScript.
Live Search
Search that updates results as you type:
Template (templates/search.html):
{% extends "base.html" %}
{% block content %}
<h1>Search</h1>
<input type="search" name="q" placeholder="Search..."
hx-get="/search" hx-target="#results"
hx-trigger="input changed delay:300ms">
{% block results %}
<div id="results">
{% for item in results %}
<div class="result">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
{% endfor %}
{% if not results %}
<p class="empty">No results found.</p>
{% endif %}
</div>
{% endblock %}
{% endblock %}
Handler:
@app.route("/search")
def search(request: Request):
q = request.query.get("q", "")
results = do_search(q) if q else []
if request.is_fragment:
return Fragment("search.html", "results", results=results)
return Template("search.html", results=results)
The browser renders the full page on first load. As the user types, htmx sends requests and Chirp responds with just the resultsblock.
Click to Edit
Inline editing that swaps between display and edit views:
Template (templates/contact.html):
{% block contact_display %}
<div id="contact-{{ contact.id }}" class="contact">
<span>{{ contact.name }} — {{ contact.email }}</span>
<button hx-get="/contacts/{{ contact.id }}/edit"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
Edit
</button>
</div>
{% endblock %}
{% block contact_edit %}
<form id="contact-{{ contact.id }}" class="contact editing"
hx-put="/contacts/{{ contact.id }}"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
<input name="name" value="{{ contact.name }}">
<input name="email" value="{{ contact.email }}">
<button type="submit">Save</button>
<button hx-get="/contacts/{{ contact.id }}"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
Cancel
</button>
</form>
{% endblock %}
Handlers:
@app.route("/contacts/{id:int}")
def show_contact(id: int):
contact = get_contact(id)
return Fragment("contact.html", "contact_display", contact=contact)
@app.route("/contacts/{id:int}/edit")
def edit_contact(id: int):
contact = get_contact(id)
return Fragment("contact.html", "contact_edit", contact=contact)
@app.route("/contacts/{id:int}", methods=["PUT"])
async def update_contact(request: Request, id: int):
form = await request.form()
contact = save_contact(id, name=form["name"], email=form["email"])
return Fragment("contact.html", "contact_display", contact=contact)
Three handlers, zero JavaScript. Each returns a fragment that htmx swaps into place.
Infinite Scroll
Load more content as the user scrolls:
Template (templates/feed.html):
{% block feed_items %}
<div id="feed">
{% for item in items %}
<article class="feed-item">
<h3>{{ item.title }}</h3>
<p>{{ item.summary }}</p>
</article>
{% endfor %}
{% if has_more %}
<div hx-get="/feed?page={{ next_page }}"
hx-target="#feed"
hx-swap="beforeend"
hx-trigger="revealed">
<span class="loading">Loading more...</span>
</div>
{% endif %}
</div>
{% endblock %}
Handler:
PAGE_SIZE = 20
@app.route("/feed")
def feed(request: Request):
page = int(request.query.get("page", "1"))
items = get_items(page=page, size=PAGE_SIZE)
has_more = len(items) == PAGE_SIZE
ctx = dict(items=items, has_more=has_more, next_page=page + 1)
if request.is_fragment:
return Fragment("feed.html", "feed_items", **ctx)
return Template("feed.html", **ctx)
The hx-trigger="revealed" attribute fires when the element scrolls into view. htmx fetches the next page and appends it with hx-swap="beforeend".
Delete with Confirmation
Delete an item with a confirmation step:
<button hx-delete="/items/{{ item.id }}"
hx-target="#item-{{ item.id }}"
hx-swap="outerHTML"
hx-confirm="Delete this item?">
Delete
</button>
@app.route("/items/{id:int}", methods=["DELETE"])
def delete_item(id: int):
remove_item(id)
return "" # Empty response removes the element
Reorder List (Drag-and-Drop)
Reorder items in a list with native HTML5 drag-and-drop. Use a hidden form for reliable form submission — populate it on drop and triggerhtmx.trigger(form, 'submit'). The server returns a Fragment with the updated list; use hx-selectto extract the target element when the response is a full page.
Template (with chirp-uisortable_list):
<div id="step-list">
<form id="reorder-form" method="post" action="/steps/reorder"
hx-post="/steps/reorder" hx-target="#step-list" hx-select="#step-list" hx-swap="outerHTML"
style="display:none">
<input type="hidden" name="from_idx" value="">
<input type="hidden" name="to_idx" value="">
</form>
{% call sortable_list() %}
{% for step in steps %}
{% call sortable_item(attrs='draggable="true" @dragstart="..." @drop="..."') %}
{{ step.name }}
{% end %}
{% end %}
{% end %}
</div>
Handler:
@app.route("/steps/reorder", methods=["POST"])
async def reorder_steps(request: Request):
form = await request.form()
from_idx = int(form.get("from_idx", 0))
to_idx = int(form.get("to_idx", 0))
updated = reorder(steps, from_idx, to_idx)
return Fragment("steps.html", "step_list", steps=updated)
See chirp-ui's DND-FRAGMENT-ISLAND for the full Alpine wiring (dataset for source index, per-item overCount for flicker-free drop indicator, form trigger on drop).
Form Validation
Submit a form and show inline errors:
<form hx-post="/register" hx-target="#form-errors" hx-swap="innerHTML">
<input name="name" placeholder="Name">
<input name="email" placeholder="Email">
<input name="password" type="password" placeholder="Password">
<div id="form-errors"></div>
<button type="submit">Register</button>
</form>
from chirp import ValidationError, hx_redirect
@app.route("/register", methods=["POST"])
async def register(request: Request):
form = await request.form()
errors = validate(form)
if errors:
return ValidationError("register.html", "form_errors", errors=errors)
create_user(form)
return hx_redirect("/welcome")
hx_redirect()is the safer default here because the same form can be
submitted either normally or via htmx.
Real-Time Notifications
Push notifications via SSE:
<div hx-ext="sse" sse-connect="/notifications" sse-swap="message">
<div id="notifications">
<!-- SSE fragments are swapped in here -->
</div>
</div>
@app.route("/notifications")
async def notifications():
async def stream():
async for event in notification_bus.subscribe():
yield Fragment("components/notification.html",
message=event.message,
time=event.timestamp,
)
return EventStream(stream())
OOB Multi-Update
Update multiple page sections in one request:
@app.route("/cart/add", methods=["POST"])
async def add_to_cart(request: Request):
item = await add_item(request)
return OOB(
Fragment("cart.html", "cart_items", items=get_cart()),
Fragment("layout.html", "cart_badge", count=cart_count()),
)
The first fragment is the main swap target. Additional fragments use hx-swap-oobto update other parts of the page.
Event Delegation for Dynamic Content
hx-on::click and similar attributes are bound when the DOM is parsed. Content that arrives later via htmx swaps (SSE, OOB, fragments) does not get new handlers. Clicks on swapped-in elements will not trigger hx-onhandlers.
Use event delegation instead: attach a single listener todocumentor a stable parent, and check whether the event target matches your selector:
<script>
document.addEventListener('click', function(e) {
var btn = e.target.closest('.copy-btn');
if (btn) {
var wrap = btn.closest('[data-copy-text]');
if (wrap) {
navigator.clipboard.writeText(wrap.dataset.copyText || '');
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
}
}
});
</script>
The same pattern works for toggles, compare switches, and any interactive element inside SSE or fragment-swapped content. The RAG demo uses AppConfig(delegation=True)for copy-btn and compare-switch; see RAG demo.
Next Steps
- Fragments -- Fragment rendering in depth
- Server-Sent Events -- Real-time patterns
- Coming from Flask -- Migration guide