htmx Patterns

Common htmx + Chirp patterns for building interactive apps

5 min read 943 words

Overview

Chirp's fragment rendering makes htmx integration seamless. These patterns demonstrate common interactive UI features with zero client-side JavaScript.

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