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
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
- 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.
- 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 carriestabindex="0"because a scrollable region must be reachable by keyboard. Reads like tabs, is NOTrole="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 becomesdocument.activeElementitself (verified live, 2026-08-22). - One sticky wrapper — the header and the bar stick
together, in a single sticky element. Two sticky children both at
inset-block-start: 0pin to the same offset and overlap. - Sections — one widget per facet, each with the
idthe anchor bar targets. - 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.
- Action bar —
.bo-form-actions, which is already sticky, already hidden in print, and already setsscroll-padding-block-endso 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-currentthe programmatic one, and neither is colour alone. - A half-done step is
pendingwith a half-filled marker —◐— and the detail in its meta line ("Partial — 1 of 2 lines received"). There is nopartialstate 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: herecurrentmeans the record you are looking at, not the step in progress. One flow, onecurrent.
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/submitreturns 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
| State | What the screen shows |
|---|---|
| Loading | The header skeletons first — status is the thing the reader came for. Sections fill in after. |
| Empty section | The 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 section | That 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 permission | The 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. |
| Sticky 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.