busy-office-ui
Menu

Using with HTMX

HTMX support is a separate stylesheet plus conventions — the core CSS has zero HTMX references, and the same data-loading/data-state attributes work with Alpine or vanilla JS.

What the integration looks like

Mid-swap, a table says so — dimmed and not interactive, so nobody clicks a row that is about to be replaced under them. Try clicking it. That is data-loading="true", and step 4 below is how it gets set.

Invoices, reloading
Invoice #VendorAmount
INV-10234Acme Supply Co.4,208.00
INV-10235Globex Industrial18,940.50
View code
<div class="bo-data-table-container" tabindex="0">
  <table class="bo-data-table" data-density="compact" data-loading="true">
    <caption class="bo-visually-hidden">Invoices, reloading</caption>
    <thead><tr><th scope="col">Invoice #</th><th scope="col">Vendor</th><th scope="col" class="bo-data-table__col--numeric">Amount</th></tr></thead>
    <tbody>
      <tr><th scope="row">INV-10234</th><td>Acme Supply Co.</td><td class="bo-data-table__col--numeric">4,208.00</td></tr>
      <tr><th scope="row">INV-10235</th><td>Globex Industrial</td><td class="bo-data-table__col--numeric">18,940.50</td></tr>
    </tbody>
  </table>
</div>

1. Add the integration stylesheet

/* extra, opt-in stylesheet — core CSS never references HTMX */
import "@busy-office/ui/css/htmx";

2. Swap lifecycle styling

<!-- Styles the classes HTMX itself toggles: -->
<!--   .htmx-indicator  → fades in while a request is in flight            -->
<!--   .htmx-swapping   → outgoing content fades                           -->
<!--   .htmx-settling   → flash-on-update highlight (reduced-motion aware) -->
<!--   [aria-busy=true] → progress cursor                                  -->

<tbody hx-get="/invoices" hx-trigger="every 30s" hx-swap="outerHTML settle:300ms">
  …rows…
</tbody>

3. JS behaviors and swaps

import { initDialogs, initDataTables } from "@busy-office/ui/js";

initDialogs();      // once, ever — event delegation survives ALL swap patterns
initDataTables();   // binds existing containers; row/tbody swaps INSIDE a bound
                    // container re-derive selection state automatically
                    // (the container listens for htmx:after:swap)

// only NEW .bo-data-table-container elements swapped in later need a call:
document.body.addEventListener("htmx:after:swap", (e) =>
  initDataTables(e.target)
);

// non-HTMX swap mechanism? re-derive manually:
// import { refreshDataTable } from "@busy-office/ui/js";
// refreshDataTable(container);

4. Loading states

<!-- The framework-agnostic loading convention: -->
<table class="bo-data-table"
       hx-on::before-request="this.dataset.loading='true'"
       hx-on::after-request="this.dataset.loading='false'">

The dimmed table at the top of this page is in exactly that state. The attribute is the framework's, not HTMX's — anything that swaps can set it, which is why the recipe above is framework-agnostic.

Or rely on HTMX's own aria-busy/.htmx-request — both are styled.

5. Motion on swapped rows and messages

Pulse the smallest region that actually changed: a cell when one value moved, the row when the server replaced the whole record, the summary total only when the total is the number being watched — flashing all three for one edit reads as the page flickering rather than as anything having changed. Never pulse while the user is typing in that region, never on first paint (everything is new then, so nothing is), and never more than about once a second per region: coalesce rapid updates into one flash instead of queueing them. And a windowed table is exempt from the row fades below — initWindowedList() inserts and evicts rows as the viewport moves, so fading each one animates scrolling rather than change, and moving many adjacent rows at once is what breaks a reader's tracking.

Rows that arrive and rows that leave, from the opt-in motion module — no new classes. Remove the node on a timer, never on animationend.

Purchase order lines — add and remove a line to see the entrance and the exit
LineItemAmount
10Bearing assembly1,240.00
20Anode kit318.50
import "@busy-office/ui/css/motion";   /* opt-in; never in the default bundle */

<!-- Insert: the row the server swapped in wears the entrance class. -->
<tr class="bo-motion-fade-in">
  <th scope="row">30</th><td>Seal kit</td>
  <td class="bo-data-table__col--numeric">96.00</td>
</tr>

/* Delete: animate the exit, then remove on a TIMER read back off the
   animation's own computed duration — never on animationend. An event that
   can fail to arrive (the row is re-swapped, hidden, or unmounted mid-flight)
   leaves a row that never leaves. Same rule, same shape, as the toast exit in
   the shipped initAlerts(). */
function removeRow(row) {
  row.classList.add("bo-motion-fade-out");
  const ms = Math.max(
    0,
    ...getComputedStyle(row).animationDuration.split(",").map((d) => {
      const n = Number.parseFloat(d.trim());
      return Number.isFinite(n) ? (d.trim().endsWith("ms") ? n : n * 1000) : 0;
    }),
  );
  if (ms <= 0) { row.remove(); return; }   /* reduced motion, or CSS not loaded */
  setTimeout(() => row.remove(), ms);
}

An inline validation message on its first appearance — it slides in from the edge it is anchored to, the same entrance a toast uses. Re-validating an already-invalid field does not replay it. Because this one arrives, it carries role="alert"; the server-rendered twin below does not — arrival decides, severity only refines.

/* First appearance of a field message: an Entrance, not an alarm.
   No shake — a wobble decorates an error the reader already has to read,
   and it would need its own reduced-motion special case. */
function showFieldError(input, id, text) {
  if (document.getElementById(id)) return;   /* already shown — don't replay */
  const msg = document.createElement("span");
  msg.className = "bo-form-field__message bo-motion-slide-in-block-start";
  msg.id = id;
  msg.setAttribute("role", "alert");   /* it ARRIVES, so it IS a live region */
  msg.textContent = text;
  input.setAttribute("aria-invalid", "true");
  input.setAttribute("aria-describedby", id);
  input.after(msg);
}

<!-- Server-rendered after a failed POST? Keep the class, DROP the role: that
     message arrives as a page LOAD, not a mutation, so a live region would be
     announcing something that never changed. Arrival decides. -->
<span class="bo-form-field__message bo-motion-slide-in-block-start"
      id="qty-err">Enter a whole number.</span>

Expected non-2xx responses

htmx 4 swaps every response except 204/304 by default — the opposite of 2.x, which discarded everything but 2xx. That is the right default for statuses your app uses deliberately — a 409 carrying the current record after a conflict, or a 422 carrying a form with validation errors — and the wrong one for a bare error response with no fragment to show, like a 500 with no body. Opt those OUT with htmx.config.noSwap:

htmx.config.noSwap = [204, 304, 500];

Found by dogfooding the reference app (2026-08-17, under htmx 2.x): the concurrency guidance rendered a conflict banner the browser then threw away — the opt-in this page used to teach for exactly that case. htmx 4 (2026-08-30) removed the need for it.

↑↓ move Enter open Esc close