What this solves
View Transitions add a smooth crossfade when an htmx-boosted link swaps the page. The catch: those same transitions — plus htmx's attribute inheritance — can flicker or wipe your content the moment an out-of-band (OOB) swap or SSE live update arrives. The browser tries to animate a live update as if it were a navigation, and the animation captures the wrong DOM.
The stable pattern is one rule: transitions are for user-initiated navigation
only; OOB and SSE updates stay out of the transition path. This tutorial is for
a hypermedia practitioner who already uses
htmx-boost navigation,
[[docs/about/core-concepts/return-values|Fragment/OOBreturn types]], and SSE,
and wants them to coexist with animations.
The short version: extend Chirp's boost layout and use thenav_linkand
sse_scopemacros. They bake the correct structure in. The longer version —
why it breaks and how to wire it by hand — is collapsed below for when you need it.
The happy path
Three steps. Extend the layout, point thesse_scopeblock at your event route,
and usenav_linkfor every link that navigates.
- 1
Extend the boost layout
chirp/layouts/boost.htmlships the correct structure: no transition on the#maincontainer, content wrapped in<div id="page-content">, andhx-select="#page-content"so boosted responses pull only the content region.{% extends "chirp/layouts/boost.html" %} {% block content %} <ol>...</ol> {% end %} - 2
Override the `sse_scope` block for live updates
The layout renders the
sse_scopeblock outside#main, so the connection survives navigations. Thesse_scopemacro wraps the connection withhx-disinherit="hx-target hx-swap"so incoming fragments land in the SSE sink instead of replacing the whole content area.{% block sse_scope %} {% from "chirp/sse.html" import sse_scope %} {{ sse_scope("/events") }} {% end %}If you put
sse_scopeinside{% block content %}instead, navigation replaces it and the connection dies.
That's the whole pattern. When you OOB-swap a region that contains links, render
those links withnav_linktoo so they keep transitioning after the swap.
The footgun this prevents
Reference template
A list view (has OOB targets — the per-item meta line) and a detail view (no OOB targets, so it gets the transition name), wired through the boost layout:
{% extends "chirp/layouts/boost.html" %}
{% block content %}
{% from "chirp/nav.html" import nav_link %}
{% if view == "list" %}
<ol>
{% for item in items %}
<li>
{{ nav_link("/item/" ~ item.id, item.title) }}
<div id="meta-{{ item.id }}"> {# OOB target — no view-transition-name above it #}
<span>{{ item.score }} points</span>
{{ nav_link("/item/" ~ item.id, "comments") }}
</div>
</li>
{% end %}
</ol>
{% elif view == "detail" %}
<div class="detail-view"> {# nav-only — safe to animate #}
{{ nav_link("/", "← Back", class="back") }}
<!-- detail content -->
</div>
{% end %}
{% end %}
{% block sse_scope %}
{% from "chirp/sse.html" import sse_scope %}
{{ sse_scope("/events") }}
{% end %}
{% block head_style %}
/* Only the nav-only detail view gets a transition name */
#page-content > .detail-view { view-transition-name: page-content; }
{% end %}
The OOB fragment you stream back keeps its links on nav_link:
{% from "chirp/nav.html" import nav_link %}
<div id="meta-{{ item.id }}" hx-swap-oob="outerHTML">
<span class="score">{{ item.score }} points</span>
{{ nav_link("/item/" ~ item.id, "comments", push_url=true) }}
</div>
Ship checklist
Before shipping an app that combines boosted navigation, View Transitions, and OOB/SSE updates:
| Check | Why |
|---|---|
Use{{ sse_scope(url) }} (or hx-disinherit + hx-target="this"on the sse-swap element) |
Without it, SSE fragments inherithx-target and wipe #main — a chirp checkERROR. |
Keep thesse_scopeblock outside the boost target |
Inside#main, navigation replaces it and the connection drops. |
Container hashx-swap="innerHTML" without transition:true |
transition:trueon the container animates OOB swaps too. |
Every navigation link usesnav_link (or carries hx-swap="innerHTML transition:true") |
The transition flag belongs on the trigger, not the container. |
view-transition-nameonly on content that changes on full navigation |
Never on a parent of an OOB/SSE target — achirp checkWARNING. |
OOB fragments that contain links render them withnav_link |
So they keep transitioning identically after the swap. |
When you have no OOB or SSE
If your app has no OOB swaps and no SSE updates, the chirp-ui app shell handles transitions for you: extend its layout, navigate with ordinary links, and the shell's default crossfade applies. No per-link attributes, no custom CSS.
{% extends "chirpui/app_shell_layout.html" %}
{% block content %}
<a href="/page-2">Next page</a> {# boosted navigation inherited from the shell #}
{% end %}
How it breaks, and wiring it by hand
The macros are the answer for almost every app. Open these only if you're
debugging the mechanics or building a layout you can't extend from
chirp/layouts/boost.html.
How it breaks: the three failure modes
1.hx-target inheritance wipes the whole tree. When an sse-connect
element sits inside a container withhx-target (e.g. #main from hx-boost),
it inherits that target. An incoming fragment swaps into#maininstead of the
SSE sink — one small fragment replaces your entire list. Fix:hx-disinherit="hx-target hx-swap"
on thesse-connect element (this is exactly what sse_scopeemits).
2.transition:trueon the container animates OOB swaps. When the swap
target hashx-swap="innerHTML transition:true", htmx wraps every swap into
that target — including OOB swaps to its descendants — in the View Transitions
API. OOB updates then trigger a full-area transition with the wrong captured
state, producing flicker or vanishing content. Fix: puttransition:trueon the
links that navigate, not on the container (this is whatnav_linkdoes).
3.view-transition-nameon a parent of OOB targets animates the block. When
a named element is an ancestor of OOB targets, each OOB update triggers the View
Transitions API for that named element. The browser treats the OOB change as a
transition of the whole block — animating it out and back in, or making it
disappear. Fix: scopeview-transition-nameto content that changes only on full
navigation.
Wiring it by hand (without the macros)
This is a simplified illustration, not a drop-in replacement for the layout — the
realchirp/layouts/boost.html also ships the <div id="page-content">wrapper
andhx-select="#page-content"that make boosted responses extract only the
content region. Match those if you need identical behavior.
SSE scope — outside the boost target, with inheritance broken:
<div hx-ext="sse" sse-connect="/events" hx-disinherit="hx-target hx-swap">
<div sse-swap="message" hx-target="this" class="sse-sink"></div>
</div>
hx-target="this"on the sse-swap element ensures htmx processes the response
(including its OOB swaps) once inheritance is broken. Place this outside
#mainso navigation never replaces it.
Container — no transition, noview-transition-name:
<div id="main" hx-boost="true" hx-target="#main" hx-swap="innerHTML">
<!-- content + OOB targets live here -->
</div>
Nav links — the transition flag on the trigger:
<a href="/story/123" hx-swap="innerHTML transition:true">Story title</a>
The swap still targets #main (inherited from hx-boost); the requesting element
carries the transition flag.
CSS — name only nav-only content:
@view-transition { navigation: auto; }
::view-transition-old(page-content) { animation: fade-out 0.15s; }
::view-transition-new(page-content) { animation: fade-in 0.2s; }
/* NOT the container, NOT a parent of any OOB target */
#main > .detail-view { view-transition-name: page-content; }