htmx Patterns

The fast mapping from raw hx-* attributes to Chirp's return types — live search, click-to-edit, infinite scroll, delete, and reorder, each a copy-paste template-plus-handler pair.

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

Overview

If you already know htmx, this page is the fast mapping from rawhx-*attributes to Chirp's return types. Each pattern is a self-contained template-plus-handler pair you can copy: live search, click-to-edit, infinite scroll, delete, reorder, and inline validation.

The recurring move is the one Chirp is built around: your handler returns a Fragment (one named template block) and htmx swaps it into place. No client JavaScript, no JSON.

When a route serves both a full page (browser navigation) and a fragment (htmx swap), return a Page and let Chirp negotiate from the request headers, rather than branching on request flags by hand.Page("search.html", "results", ...)renders the full template for a browser and just theresultsblock for an htmx request — one return, no boilerplate.

Search that updates results as you type. The page loads as a full document; each keystroke fetches just theresultsblock.

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 %}
from chirp import Page, Request

@app.route("/search")
def search(request: Request):
    q = request.query.get("q", "")
    results = do_search(q) if q else []
    return Page("search.html", "results", results=results)

Page inspects the request: a browser navigation gets the full search.html page, while htmx's keystroke requests get only theresultsblock swapped into #results.

Click to edit

Inline editing that swaps between a display view and an edit form. The display block and the edit block live in one template; three handlers swap between them.

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 %}
from chirp import Fragment, Request

@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)

These three routes return a bareFragmentbecause they are reached only by htmx swaps, never by direct browser navigation. If a route is also a landing URL someone could type or bookmark, returnPageinstead so the full document renders for a cold load.

Infinite scroll

Load more content as the user scrolls. A sentinel element firesrevealedwhen it enters the viewport, fetches the next page, and appends it.

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 %}
from chirp import Page, Request

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
    return Page(
        "feed.html", "feed_items",
        items=items, has_more=has_more, next_page=page + 1,
    )

hx-trigger="revealed" fires when the sentinel scrolls into view; Page serves the full feed to a browser and just thefeed_itemsblock to the scroll-triggered htmx request.

Delete with confirmation

Delete an item after a confirmation prompt.hx-confirmshows the browser dialog; the handler removes the row by returning nothing.

<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 ""

Reorder list (drag and drop)

Reorder items with native HTML5 drag and drop — no Sortable.js. A hidden form carries the source and target indices; on drop, Alpine populates it and calls htmx.trigger(form, 'submit'). The handler returns a Fragmentwith the reordered list, andhx-selectextracts the target element from the response.

This pattern uses the chirp-ui sortable_list / sortable_item macros (pip install chirp[ui]).

{% imports %}
  {% from "chirpui/sortable_list.html" import sortable_list, sortable_item %}
{% end %}

<div id="recipe-content">
  <form id="reorder-form" method="post" action="/reorder"
        hx-post="/reorder" hx-target="#recipe-content"
        hx-select="#recipe-content" hx-swap="outerHTML"
        style="display:none">
    {{ csrf_field() }}
    <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_unsafe='draggable="true"') %}
      {{ step.instruction }}
    {% end %}
    {% end %}
  {% end %}
</div>
from chirp import Fragment, Request

@app.route("/reorder", methods=["POST"])
async def reorder_route(request: Request):
    form = await request.form()
    from_idx = int(form.get("from_idx", 0))
    to_idx = int(form.get("to_idx", 0))
    reorder_steps(from_idx, to_idx)
    return Fragment("page.html", "recipe_content", steps=get_steps())

Source: examples/chirpui/sortable_reorder/app.py.

Form validation

Submit a form and re-render it with inline errors on failure, or redirect on success.

<form hx-post="/register" hx-target="#form-errors" hx-swap="innerHTML">
  {{ csrf_field() }}
  <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, Request

@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")

For validation rules, error shapes, and the full re-render flow, see forms and validation.

Real-time notifications

Push updates to the page after it loads with Server-Sent Events. The browser subscribes once; the handler streamsFragments as events arrive.

<div hx-ext="sse" sse-connect="/notifications" sse-swap="message">
  <div id="notifications">
    <!-- SSE fragments are swapped in here -->
  </div>
</div>
from chirp import EventStream, Fragment

@app.route("/notifications")
async def notifications():
    async def stream():
        async for event in notification_bus.subscribe():
            yield Fragment("components/notification.html", "notification",
                message=event.message,
                time=event.timestamp,
            )
    return EventStream(stream())

OOB multi-update

Update several page sections in one response. The firstFragmentis the main swap target; each additionalFragment carries hx-swap-oobto patch a different element.

from chirp import OOB, Fragment, Request

@app.route("/cart/add", methods=["POST"])
async def add_to_cart(request: Request):
    await add_item(request)
    return OOB(
        Fragment("cart.html", "cart_items", items=get_cart()),
        Fragment("layout.html", "cart_badge", count=cart_count()),
    )

Event delegation for dynamic content

Use event delegation: attach one 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. Chirp ships this for you: AppConfig(delegation=True)wires delegated copy-button and compare-switch handlers for swapped content. See the RAG demo.

Next steps