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:
Which fragment should be the primary response? Re-rendering all of
#contentwhen only#tab_contentchanged wastes queries and payload.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 |
|
The |
Predicate |
|
|
Inverted predicate |
|
The request is not targeting any of the listed IDs. Also true when |
Boolean |
|
Always / never. |
Callable |
|
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: extendsbase.htmland includes the partialtemplates/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.htmlin full.HTMX request: renders
partials/_project_list.html.No match: if the template name does not contain
old(for exampleauth/login.html),PathReplacereturns 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 specificoldif 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#contentfallback. Breadcrumbs update. The tabs swap is skipped because it only applies totab-content.Direct visit (no HTMX headers): the full document renders. Swaps are not rendered, but their
contextis 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
Swapwhoseinclude_ifis 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.