busy-office-ui
Menu

Pattern: object page — a long record, in sections

A long record, navigated in sections instead of scrolled as one feed. Who uses it: whoever owns one record end to end — a buyer opening a PO, an approver checking it before signing; many times a day, one object at a time. What "done" looks like: the reader can see the object's status without scrolling, reach any section in one click, and act on it from anywhere on the page. The long-record counterpart to record detail: same content, but too tall to take in at once, so it gains navigation of its own. Not for a record you can take in at one glance — the anchor rail and sticky action bar are overhead you only earn by being long. That one is record detail.

The screen

PO-88213 · Acme Supply Co. Pending approval
Net value
$18,940.50
Cost center
CC-4021
Requested delivery
15 Sep 2026
General information
Vendor
Acme Supply Co.
Payment terms
Net 30
Buyer
J. Kim
Order date
10 Aug 2026
Line items
Line items on PO-88213
Material Description Qty Net value
MAT-4471 Hydraulic pump, 24V 12 $9,240.00
MAT-1180 Seal kit, 60 mm 40 $1,480.50
MAT-9002 Filter cartridge 150 $8,220.00
Delivery Partial
Ship-to
Dock 4, 123 Warehouse Rd
Incoterms
DAP
Requested
15 Sep 2026
Confirmed
17 Sep 2026 2 days late
Document flow
  1. Requisition — REQ-40118
    Approved 4 Aug 2026 · M. Osei
  2. Purchase order — PO-88213 you are here
    Pending approval · $18,940.50
  3. Goods receipt
    Nothing received yet
  4. Vendor invoice
    Not yet received
Approvals
  1. Submitted by J. Kim
    10 Aug 2026 · 09:14
  2. Budget check passed
    10 Aug 2026 · 09:15 · automatic
  3. Awaiting cost-centre owner
    assigned to M. Okafor

Scroll the sections: the anchor bar follows, marking the section you are in, and the header's facts collapse away so the record gets the screen back — the title and status never leave. Every part of this screen is a component that already ships — the only thing added is initAnchorNav(), and it writes no CSS.

Anatomy

  1. Object header — a plain widget carrying the title, a status badge, and the key facts as a kv list. Status is visible before any scrolling, which is the whole reason the header sticks.
  2. Anchor bar — pagination markup used as section navigation, because it already styles [aria-current="page"]. Kept to one line and allowed to scroll sideways; it carries tabindex="0" because a scrollable region must be reachable by keyboard. Reads like tabs, is NOT role="tab" — a real tabs widget hides every panel but the active one and moves between them with the arrow keys; this bar is plain links, every section stays in the DOM and on screen, and Tab visits each link like any other link. Activating one scrolls (Enter or a click), same as clicking any anchor — Chrome/Firefox/Safari all then resume keyboard Tab from the section just jumped to, not from the top of the document, even though it never becomes document.activeElement itself (verified live, 2026-08-22).
  3. One sticky wrapper — the header and the bar stick together, in a single sticky element. Two sticky children both at inset-block-start: 0 pin to the same offset and overlap.
  4. Sections — one widget per facet, each with the id the anchor bar targets.
  5. Document flow — a timeline carrying the chain this record sits in, one step per document type. It answers "what is this connected to" and "where is it in its lifecycle" with one surface; the section below says why those are one question.
  6. Action bar — .bo-form-actions, which is already sticky, already hidden in print, and already sets scroll-padding-block-end so focus never lands under it.

Follows the review-screen anatomy contract — this is the long-record shape; see that page for when a shorter record detail is enough.

Document flow — what this record is connected to

Every ERP document sits in a chain: a purchase order came from a requisition, gets receipts against it, is matched to invoices, and is paid. A reader on this screen needs to know two things, and they are one question, not two — what is this connected to and where is it in its lifecycle. Group the related records by document type, put the types in lifecycle order, mark the current one, and the chain is the list. Render them as two blocks and the same records appear twice on one screen.

The shape is a timeline, with three rules the build of a real six-module suite settled:

  • One step per document TYPE, not per document. A PO with two receipts has one "Goods receipts" step holding both links — not two steps. Otherwise the chain's length changes with the data and stops reading as a lifecycle.
  • The instances are links inside the step, in its title. A step with no document yet carries no link and says so in its meta ("Nothing received yet") — an empty step is information, so it stays in the chain rather than disappearing.
  • The current record gets aria-current="step" and says "you are here" in words. Two channels: the marker state is the visual one, aria-current the programmatic one, and neither is colour alone.
  • A half-done step is pending with a half-filled marker — ◐ — and the detail in its meta line ("Partial — 1 of 2 lines received"). There is no partial state and there should not be: the marker is your markup, so the glyph already carries the distinction without colour, which is the channel that matters. Note the one place this differs from an approval timeline: here current means the record you are looking at, not the step in progress. One flow, one current.

Not a table, and not a kv list. A kv row is a fact about this record; these are navigable records with their own state, which is why the first attempt at this — a kv list with the status stuffed into the value — could not carry a status, a date and a link in a scannable column. A data table holds all three but implies sorting and selection that are not there, and it cannot show order or position. Not stepper either, though it is also an ordered chain with a current step: it lays its steps out horizontally with an equal share of the row each, so a step holding three invoice links breaks it — and a document with several receipts is the normal case, not the edge.

This screen deliberately shows the cost as well as the shape: Document flow and Approvals are the same component, one above the other. That is "one component, many settings" working — an ordered chain with state is one abstraction — but a reader can conflate them at a glance, so give them distinct headings and keep the flow's titles as links. If a screen needs both, they are never adjacent by accident.

Data contract

The screen is one GET of one object. Sections are regions of that response, not separate requests — anchor navigation is in-page, so there is no round trip when the reader moves between them.

  • GET /purchase-orders/:id → the whole object. Every section id in the anchor bar must exist in the response, or the link scrolls nowhere.
  • Actions post the whole object — POST /purchase-orders/:id/submit returns the re-rendered page, or a 4xx with the object plus errors. Swap the whole <main>: a partial swap would drop the sticky wrapper and the reader's scroll position with it.
  • A 409 (someone else edited it) returns the current object and a banner. htmx 4 swaps it by default — see editable grid.

States

StateWhat the screen shows
LoadingThe header skeletons first — status is the thing the reader came for. Sections fill in after.
Empty sectionThe section stays in the anchor bar and says why it is empty. Removing it would make the bar change shape between records.
Error in one sectionThat section shows the error; the rest of the object stays readable. A whole-page error for one failed facet loses the record.
Read-only / no permissionThe action bar is absent, not disabled — there is nothing to explain if the reader can never act.
Conflict (409)Banner above the sections, object re-rendered, anchor position preserved.
PrintSticky chrome and the action bar drop out; sections print in order as one continuous document.

Markup

<div class="op-sticky">                        <!-- ONE sticky wrapper -->
  <header class="bo-widget">
    <div class="bo-widget__header">
      <!-- The record's identity IS the page's heading. bo-widget__title is a
           CLASS, not an element: put it on an <h1> here and on <h2> for each
           section widget below. Every demo on this site nests inside the docs'
           own <h1>, so a page copied from one without this line ships with no
           h1 at all — axe's page-has-heading-one, found by building a real
           screen from this markup (2026-08-23). -->
      <h1 class="bo-widget__title">PO-88213 · Acme Supply Co.</h1>
      <span class="bo-badge bo-badge--warning">Pending approval</span>
    </div>
    …key facts…
  </header>
  <nav class="bo-pagination" data-anchor-nav tabindex="0" aria-label="Sections">
    <a class="bo-pagination__btn" href="#general" aria-current="page">General information</a>
    <a class="bo-pagination__btn" href="#items">Line items</a>
  </nav>
</div>

<section class="bo-widget" id="general">
  <div class="bo-widget__header"><h2 class="bo-widget__title">General information</h2></div>
  …
</section>
<section class="bo-widget" id="items">…same shape, <h2> title…</section>

<div class="bo-form-actions">                     <!-- already sticky + print-hidden -->
  <button class="bo-btn bo-btn--secondary" type="button">Discard</button>
  <button class="bo-btn" type="submit">Submit for approval</button>
</div>

Components used

complexity 3 of 4 — overview & shell tier.

↑↓ move Enter open Esc close