Toast

Toast feedback Controller

Stack host + auto-dismiss notifications — title, body, optional actions; hover/focus pauses the timer (htmx OOB or client bridge).

Layer: L2 host · Recipe: chrome-presentation — presentation / chrome. Curriculum: AGENTS.md; pick matrix: docs/agent/pick-a-surface.md; blast radius: CONSUMER_MAP.md.

Copy this

html
<div id="toast" class="toast-stack" aria-live="polite" data-toast-cap="8">
  <div class="toast toast-enter" data-toast-level="success" data-remove-after="8s" role="status">
    <span class="toast__icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg></span>
    <div class="toast__body">
      <div class="toast__title">Saved</div>
      <div class="toast__message">Your changes are live.</div>
      <div class="toast__actions"><a class="toast__action" href="#toast">View record</a><button type="button" class="toast__action" data-toast-dismiss>Dismiss</button></div>
    </div>
    <button type="button" class="toast__close" data-toast-dismiss aria-label="Dismiss"></button>
  </div>
</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="toast-root" data-dz-region>…</div>

How to use it

Seams

  • stack host #toast.toast-stack receives OOB afterbegin
  • data-remove-after + data-toast-level on each .toast
  • optional .toast__title / __message / __actions slots
  • person: data-toast-composition=person + __avatar / __actor
  • data-toast-dismiss removes the nearest .toast
  • showToast CustomEvent or window.dz.toast for client path
  • host injects .toast__progress TTL bar (pauses with timer)
  • level icon .toast__icon (inline SVG; host ensures if missing)
  • swipe toward stack edge dismisses (same leave path)
  • data-toast-sound + page dzCue opt-in for enter cue

Do / Don't

DoDon't
emit structured toasts (title + message + actions) from the exchange response via with_toastbuild a client notification SPA store or copy third-party Alpine toast idioms
let hover/focus pause auto-dismiss so users can read or activate actionsforce-dismiss while the pointer is over the toast
use actor_name for person composition — not a new severity colouradd data-toast-level=message as a fake tone

Pitfalls

  • do not morph the toast stack — replace/afterbegin only
  • do not invent Alpine notify stacks — use OOB + this host
  • message text must be textContent (never innerHTML from detail)
  • gallery fire buttons are demo chrome — production uses with_toast or HX-Trigger showToast
  • never enable sound by default on product shells

Keyboard / AT

  • role=status (or alert for error); aria-live on the stack
  • dismiss control is keyboard-activatable
  • focus inside the stack pauses auto-dismiss
  • sound/haptic cues are opt-in (meta or data-cue-sound)

Related parts

button alert

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

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

NodeAttrConstraint
[data-toast-level]data-toast-levelone of ['info', 'success', 'warning', 'error']
[data-toast-level]data-remove-afterpresent (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: toast — stack host + auto-dismiss notification unit.

Dual-lock unit is the toast root (``[data-dz-toast-level]`` on ``.dz-toast``)
plus the stack host (``#dz-toast.dz-toast-stack``). Level, auto-dismiss delay,
title, message, and optional action row are host-owned.

Page chrome (decision 0011 / stem ``page-chrome-toast``): viewport stack,
default TTL 8s (10s error), pause on hover/focus, leave before remove. TTL
progress bar is host-injected (not a contract-required node). Optional person
composition (``data-dz-toast-composition=person``) and sound attr are optional
slots (ssr-client-slot-parity); host swipe-dismiss is pure behaviour.

Server emit: ``dazzle.http.runtime.response_helpers.with_toast``.
Client emit: ``showToast`` / stack ``toast`` events (``controllers/dz-toast.js``).

Contract selectors use ``[data-dz-*]`` only — the kit has no CSS class engine.
"""

from __future__ import annotations

from contracts._kit import DomContract, Node, OneOf, Present

DOM_CONTRACT = DomContract(
    part="toast",
    root="[data-dz-toast-level]",
    nodes=(
        Node(
            "[data-dz-toast-level]",
            attrs={
                "data-dz-toast-level": OneOf("info", "success", "warning", "error"),
                "data-dz-remove-after": Present(),
            },
        ),
    ),
)

__all__ = ["DOM_CONTRACT"]

Notes

Dual-lock root .toast + stack #toast.toast-stack. Decision 0011: viewport host, 8s/10s TTL, pause, leave, TTL progress, icons, person composition (actor_name), swipe-dismiss, opt-in sound via dzCue. Server: with_toast(..., title=…, actions=…, actor_name=…). Not Alpine notify.

Source files

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

site/registry.py · contracts/toast.py · components/fragments.css:31 · components/fragments.css:585 · components/sitespec.css:1703 · components/toast.css:2 · base/design-system.css:610 · controllers/dz-toast.js · controllers/dz-cue.js