Targeting & Nested Navigation

In a simple HTMX application, every navigation request swaps the same primary container (such as #content). Real interfaces quickly add nested regions: tabs, subtabs, drawer panels, inline filters.

When a user clicks a tab inside #content, two questions come up:

  1. Which fragment should be the primary response? Re-rendering all of #content when only #tab_content changed wastes queries and payload.

  2. Which other regions should ride along? A tab click may need to update the tab header, but not the sidebar or breadcrumbs.

django-htmx-nav answers both with one condition vocabulary, used by the primary partial (partial=) and by out-of-band updates (Swap(..., include_if=...)).

The Unified Target Vocabulary

Both partial= (as mapping values) and include_if= accept the same Target conditions:

Target type

Example

Matches when

DOM ID string

"tab-content" or "#tab-content"

The HX-Target header equals the ID (a leading # and any tag prefix like div# are stripped).

Predicate

targeting("tab-content", "subtabs")

HX-Target is any of the listed IDs.

Inverted predicate

not_targeting("sidebar")

The request is not targeting any of the listed IDs. Also true when HX-Target is absent.

Boolean

True / False

Always / never.

Callable

lambda req: req.user.is_staff

The callable returns a truthy value for the request.

Specifying the Primary Partial (partial=)

partial decides what render_nav returns as the main response on HTMX requests. On non-HTMX requests, render_nav renders template_name in full. partial accepts the forms below.

1. Default block (#content)

When omitted, partial falls back to HTMX_NAV_DEFAULT_PARTIAL (default "#content"):

return render_nav(request, "projects/detail.html", context)
# HTMX request:     renders "projects/detail.html#content"
# Non-HTMX request: renders all of "projects/detail.html"

2. Explicit block or standalone template

# A different block in the same template
render_nav(request, "projects/detail.html", context, partial="#tickets")

# A standalone partial file
render_nav(request, "projects/detail.html", context, partial="partials/_tickets.html")

3. Target routing dictionary

Map partials to Target conditions. Entries are checked in order and the first match wins:

render_nav(
    request,
    "projects/detail.html",
    context,
    partial={
        "#tab_content": targeting("tab-content"),
        "#content": True,  # fallback
    },
)

Tip

End every routing dictionary with a True entry. If nothing matches, the mapping resolves to None and the full template is rendered, which is rarely what an HTMX request wants.

4. Callable

A function taking request and returning a partial (or None for the full template):

def resolve_partial(request):
    if htmx_target_is(request, "modal-body"):
        return "#modal"
    return "#content"


render_nav(request, "projects/detail.html", context, partial=resolve_partial)

The callable receives only the request. If you need the template name, use a resolver (next section).

5. Separate page and partial files (PathReplace)

If your team keeps partials in their own files rather than {% partialdef %} blocks, organize templates by directory:

  • templates/pages/project_list.html: extends base.html and includes the partial

  • templates/partials/_project_list.html: the partial markup

PathReplace derives the partial path from the base template name:

from htmx_nav import PathReplace, render_nav

render_nav(
    request,
    "pages/project_list.html",
    context,
    partial=PathReplace("pages/", "partials/_"),
)
  • Non-HTMX request: renders pages/project_list.html in full.

  • HTMX request: renders partials/_project_list.html.

  • No match: if the template name does not contain old (for example auth/login.html), PathReplace returns the name unchanged, so the view renders that template as-is.

  • Path replacement: replaces the first occurrence of old. It works for root templates (pages/x.html $\rightarrow$ partials/_x.html) as well as namespaced app templates (app/pages/x.html $\rightarrow$ app/partials/_x.html). Use a more specific old if needed.

To make this the project-wide default see HTMX_NAV_DEFAULT_PARTIAL

Resolvers can also be mapping keys, mixed with plain paths and blocks:

partial={
    "partials/_tab_content.html": targeting("tab-content"),
    PathReplace("pages/", "partials/_"): True,  # fallback
}

6. Swaps only: partial=None

partial=None disables partial selection. template_name is rendered in full on every request, HTMX or not, and any swaps are appended on HTMX requests.

Use it for non-navigation endpoints: actions that are only ever triggered by HTMX, have no full-page equivalent, and don’t change the URL (deleting a row, toggling a flag, dismissing a banner):

def delete_ticket(request, ticket_id):
    get_object_or_404(Ticket, id=ticket_id).delete()
    messages.success(request, f"Ticket #{ticket_id} deleted.")

    return render_nav(
        request,
        "tickets/_empty_state.html",
        partial=None,
        swaps=[
            Swap.delete(f"ticket-row-{ticket_id}"),
            Swap.text("open-tickets-count", str(Ticket.objects.filter(status="open").count())),
            Swap("nav/_messages.html", target_id="messages", include_if=has_messages),
        ],
    )

Coordinating Main Partials with Companion Swaps

Combining partial= routing with Swap(..., include_if=...) is where this pays off. Consider a project page with tabs:

def project_detail(request, pk):
    project = get_object_or_404(Project, pk=pk)

    return render_nav(
        request,
        "projects/detail.html",
        {"project": project},
        # 1. Main response partials:
        partial={
            "#tab_content": targeting("tab-content"),
            "#content": True,
        },
        # 2. Out-of-band updates
        swaps=[
            # Only when just the tab content is swapped: refresh the tab header
            Swap("projects/_tabs.html", target_id="tabs", include_if=targeting("tab-content")),
            # No include_if: rides along on every HTMX request
            Swap("nav/_breadcrumbs.html", target_id="breadcrumbs"),
        ],
        title=project.name,
    )

How requests resolve

  • Tab click (HX-Target: tab-content): the primary response is #tab_content. The tabs swap matches and updates the active tab. Breadcrumbs also ride along, since they are unconditional.

  • Navigation click (HX-Target: content): the primary response is the #content fallback. Breadcrumbs update. The tabs swap is skipped because it only applies to tab-content.

  • Direct visit (no HTMX headers): the full document renders. Swaps are not rendered, but their context is still merged into the page context as a fallback, so shell templates get the same data as on HTMX requests.

To skip breadcrumbs on tab clicks, add include_if=not_targeting("tab-content").

Conditionals Beyond Targeting

include_if can depend on application state, permissions, or query parameters:

from htmx_nav import Swap, has_messages, not_targeting, targeting

swaps = [
    Swap("nav/_tabs.html", target_id="tabs", include_if=targeting("tab-content", "subtabs")),
    # Skip the sidebar when the request came from inside the sidebar
    Swap("nav/_sidebar.html", target_id="sidebar", include_if=not_targeting("sidebar")),
    # Only when Django messages are queued
    Swap("nav/_messages.html", target_id="messages", include_if=has_messages),
    # Permission check
    Swap("nav/_admin_toolbar.html", target_id="admin-toolbar", include_if=lambda req: req.user.is_staff),
]

Performance: Short-Circuit Evaluation

Keeping targeting logic in Python, rather than branching in templates on request.headers.HX_Target, means:

  • A Swap whose include_if is false is never rendered.

  • If that swap relies on isolated context or helper functions, those functions and their underlying database queries are never triggered.

  • Templates stay agnostic of HTTP headers, so they are easier to reuse and test in isolation.