Tabs

Tabs interactive SpriteControllerEndpoint

A lazy tab strip — an honest link-strip (buttons + aria-current, no unkept role=tablist). Each panel hx-gets its content the first time it is shown.

Layer: L1 surface · Recipe: unset — see docs/agent/pick-a-surface.md. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

Active on the Pro plan, renewing 1 August.

Copy this

html
<!-- icons: include the icon sheet once per page (see the Setup section, #setup) -->
<div class="tabs" data-tabs>
  <div class="tabs__list"><button type="button" class="tabs__tab" aria-current="true" data-tab-target="hm-tab-overview">Overview</button><button type="button" class="tabs__tab" data-tab-target="hm-tab-activity">Activity</button><button type="button" class="tabs__tab" data-tab-target="hm-tab-settings">Settings</button></div>
  <div id="hm-tab-overview" class="tabs__panel">
    <p class="hm-demo-muted">Active on the Pro plan, renewing 1 August.</p>
  </div>
  <div id="hm-tab-activity" class="tabs__panel" hidden hx-get="/mock/tabs/activity" hx-trigger="intersect once" hx-swap="innerHTML">
    <div class="tabs__loading"><svg class="icon" aria-hidden="true"><use href="#i-loader-circle"/></svg></div>
  </div>
  <div id="hm-tab-settings" class="tabs__panel" hidden hx-get="/mock/tabs/settings" hx-trigger="intersect once" hx-swap="innerHTML">
    <div class="tabs__loading"><svg class="icon" aria-hidden="true"><use href="#i-loader-circle"/></svg></div>
  </div>
</div>

Server exchange

When the client affordance finishes (click, confirm, keystroke…), htmx issues this request. Your API must return the response fragment in the table — usually HTML, not JSON (unless the partial says otherwise). Dazzle often renders these routes from the app model; a standalone HTMX4 app implements them explicitly. The Envelope column is the exchange envelope (part of the Swap contract) — what the response may re-emit relative to the persistent slot.

Do not reimplement the gallery. Flash toasts (e.g. “Deleted (demo).”), /mock/* paths, and other static-site scaffolding are demo-only (MOCK_HTMX in site/build_site.py). They are not Hyperpart surface and not a product API. If an agent is stuck “making the toast work,” stop — implement the exchange row below instead.

RequestTriggerResponse fragmentSwapEnvelopeStates
GET /app/{region}/{tab}a panel, the first time it is revealed (`intersect once`); an eager panel on `load`the panel's content fragment (rows, a form, a chart — whatever the tab shows)innerHTML of the panel itself (no hx-target)host_ownedloading populated error

Swap contract

Agent-visible HTMX topology (ADR-0054 / decision 0012). exchange envelope = what the response may re-emit relative to the persistent slot (body_only | outer | none | host_owned | document). dual-lock validates part markup only — not this envelope. Stem: stems/morph-safe-hypermedia.md; decision: docs/decisions/0012-swap-identity-contract.md.

Gallery mocks may approximate morph with innerHTML — production follows the Swap + Envelope columns in Server exchange.

Exchanges (swap · envelope)

  • GET /app/{region}/{tab} → innerHTML of the panel itself (no hx-target) · envelope=host_owned

Envelope rules

  • body_only — innerHTML / innerMorph into a slot; response is interior only (no re-wrap of slot id / nested data-dz-region).
  • outer — outerHTML / outerMorph; response may carry identity.
  • none — no HTML swap (JSON/204/bytes; client or OOB companion).
  • host_owned — swap target/mode chosen by the host button’s hx-target / hx-swap.
  • document — full navigation / document load (not a fragment).
  • Slot owns stable id / domain keys; state in DOM, not Alpine.

Envelope response examples

What the server returns for each exchange on Tabs. Match the exchange envelope; dual-lock still applies to interior markup.

GET /app/{region}/{tab} · envelope=host_owned

Correct responses for host_owned — follow the initiating control’s hx-target / hx-swap.

Do — correct response body

html
<!-- envelope=host_owned → match the *button's* hx-target / hx-swap -->
<!-- Example A: button hx-swap=delete → empty body (row removed) -->

<!-- Example B: button hx-target=#region-body hx-swap=innerHTML → body_only fragment -->
<div class="dz-list-row">remaining rows…</div>

<!-- Example C: button hx-swap=outerHTML on a card → full card root -->
<div class="dz-card" id="invoice-42">…updated card…</div>

Don’t — violates host_owned

html
<!-- WRONG: assuming a fixed envelope without reading the host affordance -->
<!-- e.g. always returning outer chrome when the button asked for delete/none -->
<div data-dz-region id="region-x">…</div>

How to use it

Seams

  • tab (`__tab` button) + tab list (`__list`) + panel (`__panel`)
  • aria-current marks the selected tab; panels toggle scoped to .tabs
  • hidden panels may carry intersect once lazy-load; first panel is eager

Do / Don't

DoDon't
mark the active tab with aria-current and show its panelfake tabs with links that reload the whole page for every panel
square active underline (border-radius: 0 on strip tabs)inherit base button radius so the brand bar curves at the ends

Pitfalls

  • no role=tablist without the roving-tabindex/arrow-key contract — honest buttons
  • do not use <a href> for in-page panel switches (wrong affordance)
  • active underline must stay square — reset border-radius (base button is radius-sm)
  • panel reveal is scoped to THIS root so multiple tab sets coexist

Keyboard / AT

  • Tab reaches each tab button; activation is Enter/Space (button default)
  • lazy panels load on first reveal via intersect once

Related parts

button

DOM contract

What the emitted HTML must satisfy — the table is the required surface; Python under contracts/ is the package-internal dual-lock CI runs (tests/test_contracts.py), not an app route. Standalone HTMX4: implement the API so responses match this markup. Dazzle: the agent emits SSR that already satisfies it. Do not invent attrs outside these tables. For request/response wiring see Server exchange.

contracts/tabs.py

Required in the DOM: root [data-tabs] (part tabs). Emit only these attributes — inventing extras is fine only if controllers ignore them; omitting required ones fails CI (tests/test_contracts.py).

NodeAttrConstraint
[data-tabs]——
[data-tab-target]data-tab-targetpresent (any value)

Module source

Import path is monorepo/package-local (from contracts._kit import …). Source-token form often uses data-*; gallery demos above are unprefixed. Do not copy this into app routes.

python
"""HYPERPART: tabs — tablist root + panel targets.

Leftover-honest catalog (cycle 2185): valid ``?tab=`` rides.
Leftover junk (``ghost``, ``zzz``) must not invent the first
declared tab when a later sibling is rest.
"""

from contracts._kit import DomContract, Node, Present

DOM_CONTRACT = DomContract(
    part="tabs",
    root="[data-dz-tabs]",
    nodes=(
        Node("[data-dz-tabs]", attrs={}),
        Node("[data-dz-tab-target]", attrs={"data-dz-tab-target": Present()}),
    ),
)

__all__ = ["DOM_CONTRACT"]

Notes

Taxonomy: tab (__tab button), tab list (__list), panel (__panel). Stem selection-strip-honest: buttons because this is in-page state, not navigation; no role=tablist without roving-tabindex/arrows. Active indicator is a square bottom border (force border-radius: 0 — base button radius would curve the underline). tabs.js + lazy intersect once panels.

Source files

One logical Hyperpart, 5 code items (CSS layered, JS bundled). Bound by HYPERPART: tabs — python tools/hyperpart.py tabs lists them.

site/registry.py · contracts/tabs.py · components/tabs.css:1 · controllers/dz-tabs.js · mock /mock/tabs