Skip to content

htmx Patterns

kokage-ui includes ready-made components for common htmx interaction patterns. htmx is bundled locally — no CDN needed.

AutoRefresh

Polls a URL at a regular interval and replaces its content.

from kokage_ui import AutoRefresh, Span

AutoRefresh(
    Span("Loading...", cls="loading loading-spinner"),
    url="/api/stats",
    interval=3,
)

This renders a <div> with hx-get="/api/stats" and hx-trigger="every 3s".

Parameter Type Description
*children Any Initial content (shown before first load)
url str URL to poll
interval int Refresh interval in seconds (default: 5)
target str | None Target selector (default: self)
swap str Swap method (default: "innerHTML")

Example: Live Stats

@ui.page("/")
def home():
    return Page(
        AutoRefresh(
            Span("Loading..."),
            url="/api/stats",
            interval=3,
        ),
        title="Dashboard",
    )

@ui.fragment("/api/stats")
def stats(request: Request):
    return Stats(
        Stat(title="Users", value=str(get_user_count())),
        Stat(title="CPU", value=f"{get_cpu()}%"),
    )

SearchFilter

An input that triggers server-side search on keyup with debounce.

from kokage_ui import SearchFilter

SearchFilter(
    url="/api/search",
    target="#results",
    placeholder="Search users...",
    delay=300,
)

This renders an <input type="search"> with hx-get and hx-trigger="keyup changed delay:300ms".

Parameter Type Description
url str Search endpoint URL
target str CSS selector for results container
placeholder str Input placeholder (default: "Search...")
name str Input name attribute (default: "q")
delay int Debounce delay in ms (default: 300)
@ui.page("/")
def home():
    return Page(
        SearchFilter(url="/api/users/search", target="#user-table"),
        Div(id="user-table"),
        title="Search",
    )

@ui.fragment("/api/users/search")
def search(request: Request, q: str = ""):
    filtered = [u for u in users if q.lower() in u.name.lower()] if q else users
    return DaisyTable(
        headers=["Name", "Email"],
        rows=[[u.name, u.email] for u in filtered],
    )

InfiniteScroll

Loads more content when the element scrolls into view.

from kokage_ui import InfiniteScroll

InfiniteScroll(
    url="/api/items?page=2",
    target="#item-list",
    swap="beforeend",
)

This renders a <div> with hx-trigger="revealed" — htmx loads the URL when the element enters the viewport.

Parameter Type Description
url str Next page URL
target str | None Insert target selector
swap str Swap method (default: "beforeend")
indicator Any Custom loading indicator (default: spinner)

Example: Paginated List

@ui.fragment("/api/items")
def items(request: Request, page: int = 1):
    items = get_items(page=page)
    result = [Div(item.name) for item in items]
    if has_more(page):
        result.append(InfiniteScroll(url=f"/api/items?page={page + 1}"))
    return result

SSEStream

Receives Server-Sent Events for real-time updates. Requires the htmx SSE extension.

from kokage_ui import SSEStream, Page

Page(
    SSEStream(
        "Waiting for events...",
        url="/api/events",
        event="update",
    ),
    title="Live Feed",
    include_sse=True,  # Required!
)
Parameter Type Description
*children Any Initial content
url str SSE endpoint URL
event str SSE event name to listen for (default: "message")
swap str Swap method (default: "innerHTML")

Important

You must set include_sse=True on the Page (or Layout) to load the htmx SSE extension.

Example: SSE Endpoint

from sse_starlette.sse import EventSourceResponse

@app.get("/api/events")
async def events():
    async def generate():
        while True:
            data = get_latest_data()
            yield {"event": "update", "data": f"<div>{data}</div>"}
            await asyncio.sleep(1)
    return EventSourceResponse(generate())

ConfirmDelete

A delete button that shows a confirmation dialog before sending an hx-delete request.

from kokage_ui import ConfirmDelete

ConfirmDelete(
    "Delete",
    url="/api/items/123",
    confirm_message="Are you sure you want to delete this item?",
    target="#item-123",
)
Parameter Type Description
*children Any Button text
url str DELETE endpoint URL
confirm_message str Confirmation dialog message (default: "Are you sure?")
target str | None Target selector to update after deletion
swap str Swap method (default: "outerHTML")

The default styling is btn btn-error btn-outline.

HxSwapOOB

Out-of-Band Swap — updates elements outside the main htmx target.

from kokage_ui import HxSwapOOB

# Return from a fragment to update multiple elements
@ui.fragment("/api/action", methods=["POST"])
def action(request: Request):
    return [
        Div("Main content updated"),
        HxSwapOOB(
            Span("42", cls="badge"),
            target_id="notification-count",
        ),
    ]
Parameter Type Description
*children Any Content to insert
target_id str ID of the element to update
swap str Swap method (default: "true" = innerHTML)

This renders a <div id="notification-count" hx-swap-oob="true"> that htmx will use to update the element with that ID, regardless of the main target.

DependentField

A field that updates when another field changes, using htmx to fetch new options from the server.

from kokage_ui import DependentField

DependentField(
    url="/api/cities",
    trigger_name="country",
    target="#city-select",
)
Parameter Type Description
url str Endpoint to fetch updated content
trigger_name str Name of the field that triggers the update
target str CSS selector of the element to update

Example: Country → City Cascade

@ui.page("/")
def form():
    return Page(
        Form(
            DaisySelect(
                "Country", name="country",
                options=[("us", "US"), ("jp", "Japan")],
                hx_get="/api/cities", hx_target="#city-select",
            ),
            Div(id="city-select"),
        ),
    )

@ui.fragment("/api/cities")
def cities(country: str = ""):
    city_map = {"us": ["New York", "LA"], "jp": ["Tokyo", "Osaka"]}
    options = [(c, c) for c in city_map.get(country, [])]
    return DaisySelect("City", name="city", options=options)

InlineEdit

Click-to-edit pattern — click a value to switch to an inline edit form, save with htmx PATCH, or cancel to return to display mode.

Display Mode

from kokage_ui.htmx import InlineEdit

InlineEdit("Alice", edit_url="/users/1/edit/name", name="name")

This renders a <div> with the value and a hover-revealed edit button. Clicking the button fetches the edit form via hx-get.

Parameter Type Default Description
*children Any (required) Display content (the current value)
edit_url str (required) GET URL to fetch the edit form
name str (required) Field name (for identification)
edit_label str "✏️" Edit button label

Edit Mode

InlineEdit.form(
    value="Alice",
    name="name",
    save_url="/users/1",
    cancel_url="/users/1/view/name",
)

Returns a <form> with the input field, save button (hx-patch), and cancel button (hx-get).

Parameter Type Default Description
value str "" Current field value
name str (required) Field name (input name)
save_url str (required) PATCH URL to save the value
cancel_url str (required) GET URL to return to display mode
input_type str "text" Input type attribute
save_label str "✓" Save button label
cancel_label str "✕" Cancel button label

Example: Editable User Table

from fastapi import FastAPI, Request
from kokage_ui import KokageUI, Page, Div
from kokage_ui.components import DaisyTable
from kokage_ui.htmx import InlineEdit

app = FastAPI()
ui = KokageUI(app)

users = {"1": {"name": "Alice", "email": "alice@example.com"}}

@ui.page("/")
def index():
    rows = [
        [
            InlineEdit(u["name"], edit_url=f"/users/{uid}/edit/name", name="name"),
            InlineEdit(u["email"], edit_url=f"/users/{uid}/edit/email", name="email"),
        ]
        for uid, u in users.items()
    ]
    return Page(DaisyTable(headers=["Name", "Email"], rows=rows), title="Users")

@ui.fragment("/users/{user_id}/edit/{field}")
def edit_field(user_id: str, field: str):
    return InlineEdit.form(
        value=users[user_id][field],
        name=field,
        save_url=f"/users/{user_id}",
        cancel_url=f"/users/{user_id}/view/{field}",
    )

@app.patch("/users/{user_id}")
async def update_user(user_id: str, request: Request):
    data = await request.form()
    field = str(data["_field"])
    users[user_id][field] = str(data[field])
    return InlineEdit(
        users[user_id][field],
        edit_url=f"/users/{user_id}/edit/{field}",
        name=field,
    )

@ui.fragment("/users/{user_id}/view/{field}")
def view_field(user_id: str, field: str):
    return InlineEdit(
        users[user_id][field],
        edit_url=f"/users/{user_id}/edit/{field}",
        name=field,
    )

Using with DataGrid

from kokage_ui.data.datagrid import DataGrid

DataGrid(
    model=User,
    rows=users,
    cell_renderers={
        "name": lambda val, row: InlineEdit(
            val,
            edit_url=f"/users/{row.id}/edit/name",
            name="name",
        ),
    },
)

See Also