View Transitions + OOB — The Stable Pattern

Add crossfade animations to boosted navigation without OOB or SSE updates wiping the page — by extending Chirp's boost layout

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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. 1

    Extend the boost layout

    chirp/layouts/boost.htmlships the correct structure: no transition on the #main container, content wrapped in <div id="page-content">, and hx-select="#page-content"so boosted responses pull only the content region.

    {% extends "chirp/layouts/boost.html" %}
    {% block content %}
      <ol>...</ol>
    {% end %}
    
  2. 2

    Override the `sse_scope` block for live updates

    The layout renders thesse_scope block outside #main, so the connection survives navigations. Thesse_scopemacro wraps the connection with hx-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_scope inside {% 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.