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
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>
@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 Redirect("/welcome")
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.
Next Steps
- Fragments -- Fragment rendering in depth
- Server-Sent Events -- Real-time patterns
- Coming from Flask -- Migration guide