Accordion

Accordion interactive Primitive

Native <details> group; single-open via the HTML name= attribute — opening one closes its siblings, zero JS.

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.

What is a Hyperpart?
A server-rendered partial plus its exchange contract — the htmx-native unit of reuse.
Do I need a client framework?
No — state lives on the server and htmx swaps the markup.
Can two panels be open at once?
Not while they share a name=. Drop the attribute for an independent, multi-open group.

Copy this

html
<div class="accordion">
  <details class="accordion__item" name="hm-acc" open>
    <summary class="accordion__trigger">What is a Hyperpart?</summary>
    <div class="accordion__panel">A server-rendered partial plus its exchange contract — the htmx-native unit of reuse.</div>
  </details>
  <details class="accordion__item" name="hm-acc">
    <summary class="accordion__trigger">Do I need a client framework?</summary>
    <div class="accordion__panel">No — state lives on the server and htmx swaps the markup.</div>
  </details>
  <details class="accordion__item" name="hm-acc">
    <summary class="accordion__trigger">Can two panels be open at once?</summary>
    <div class="accordion__panel">Not while they share a name=. Drop the attribute for an independent, multi-open group.</div>
  </details>
</div>

Server exchange

This Hyperpart has no server exchange — it is presentation or client chrome only. htmx does not issue a request on this part's behalf. If you put an affordance (hx-*) on a control that uses this markup, that action's exchange belongs to the action, not this part. See Swap contract for host-owned envelopes.

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.

No host HTMX exchange on this part — presentation or client chrome only. exchange envelope: n/a.

If a host wraps this markup in hx-*, that host owns the swap contract (sole identity + envelope). Prefer innerMorph / outerMorph for stable slots; replacement for flash; body-only responses under inner swaps.

Envelope response examples

This part has no owned exchange (envelope n/a). If a host adds hx-*, that host’s envelope applies — typically body_only:

html
<!-- Host wraps this presentation part with hx-* (host owns envelope) -->
<!-- Prefer: hx-swap="innerMorph" hx-target="#panel-body" -->
<!-- Server returns body_only interior for #panel-body -->
<div class="dz-stack">content…</div>

Do not re-own the slot:

html
<!-- WRONG: server returns the presentation root with a new id every poll -->
<div id="accordion-root" data-dz-region>…</div>

How to use it

Seams

  • `details.accordion__item` + shared `name=` for exclusive group
  • `summary.accordion__trigger` — native toggle, no controller

Do / Don't

DoDon't
Share one name= across peer items for single-open FAQCopy menubar exclusive controller onto accordion panels

Pitfalls

  • Missing or mismatched name= → multi-open (not exclusive)
  • Do not add exclusive-open JS here — browser name= is the contract
  • Do not confuse with tree (multi_open forest) or menubar (controller chrome)

Keyboard / AT

  • details/summary expose expanded state natively
  • Keyboard: Enter/Space on summary

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/accordion.py

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

NodeAttrConstraint
.accordion——

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: accordion — native details group (single-open via name=).

Dual-lock unit is the accordion root. Item open state, panel body, and
shared ``name=`` exclusive-open policy are host-owned. Class
``.dz-accordion`` is the stable substrate root (gallery CSS; no
FragmentRenderer emit yet).
"""

from contracts._kit import DomContract, Node

DOM_CONTRACT = DomContract(
    part="accordion",
    root=".dz-accordion",
    nodes=(Node(".dz-accordion", attrs={}),),
)

__all__ = ["DOM_CONTRACT"]

Notes

Open intent: exclusive via native name= on peer <details> (stem details-open-intent) — zero JS. Gallery probe accordion.exclusive_open. No aria-expanded wiring: details/summary carry it. Drop name= only when multi-open FAQ is intentional.

Source files

Canonical registration in the registry. No dedicated controller — CSS for this part lives in the layered bundle.

site/registry.py · contracts/accordion.py