Pattern: master-detail
M2 of the four data-maintenance patterns (and the most common one in practice): a list of records, and clicking a row opens that record's fields in a panel beside the list or a dialog over it. A back-office user uses this all day — find the vendor, fix two fields, move on — and "done" means the list stays put, keeping their scroll position, filters and sort, while only the detail changes. Not when one record deserves the whole screen — the split exists so a user can move ALONG the list without losing their place; if they are working one record end to end, the panel is just a narrower object page.
The screen — side panel
| Vendor | Country | Status |
|---|---|---|
| Umbrella Logistics GmbH | DE | Active |
| Globex Industrial | US | Active |
| Acme Supply Co. | GB | Blocked |
The list keeps its scroll, filters and sort while the panel changes — that is the point of the pattern, and it is why the detail is a swap target rather than a navigation.
Which row is open is carried by aria-current on its link, and
the framework ships no tint for it. That is a real gap, stated rather than
papered over: data-row-state covers dirty,
error and warning — the states the framework owns — and a
"currently open record" is application state, so there is no
data-row-state="selected" to reach for. Add your own rule against
[aria-current] if you want the row tinted; the announcement works
either way, which is the half that must not be left to CSS.
The same record on a narrow screen — a drawer
Below the shell breakpoint a side panel has nowhere to sit, so the detail arrives as a full-width drawer over the list. This page used to say that and show nothing — here it is, at any width, so the promise is inspectable:
It is not full-width, deliberately:
.bo-offcanvas is min(18rem, 85vw), so a sliver of the list
stays visible behind it and the user keeps their place. Same record, same fields, same
initDialogs() wiring — only the container changes. It is a real
<dialog>, so Escape closes it and focus returns to the trigger
without any code of ours. Selection survives closing it, which is
the whole reason the list is not re-rendered.
The same record in a dialog
When the detail is an edit rather than a read, a modal keeps the user from
losing it behind the list. Same record, same fields, different container — and the
same initDialogs() wiring as every other dialog.
Zero new CSS. This pattern is the
data table, a
card as the panel,
key-value facts for the read view, and
dialog or
offcanvas as the container. There is no
.bo-master-detail, and there should not be.
Which container — and this is the decision that goes wrong
| Use | When | Why not the others |
|---|---|---|
| Side panel | reading and light edits, and the user compares records | keeps the list visible, so "next record" costs one click and no context |
| Dialog | a focused edit that must be finished or abandoned | modality is the feature — it stops a half-edited record being forgotten behind the list |
| Full page | the record has its own tabs, attachments and audit trail | a panel that needs to scroll independently and hold tabs is a page wearing a costume — use record detail |
| Inline row edit | one or two fields, and the user is scanning the list | opening anything at all is too much ceremony — use M1 |
On narrow screens the choice collapses: below the shell's breakpoint a side panel has nowhere to sit, so it becomes a drawer over the list. Design the panel so that works — one column, no side-by-side fields.
Anatomy
- Master list — data table;
the row's first cell is a
<th scope="row">holding a real link, so the record is reachable, bookmarkable and openable in a new tab. - Selection state —
aria-selectedon the<tr>, which is the one channel the framework ships. There is nodata-row-state="selected"— see the note below the demo above for why, and add your own tint rule against[aria-selected="true"]if you want one. - Detail container — a card beside the list, or dialog/offcanvas over it.
- Detail body — key-value facts to read, a detail form to write.
Data contract
| Request | Returns / on failure |
|---|---|
| GET /vendors | the master list; the detail region renders empty until a row is chosen |
| GET /vendors/:id | the detail fragment only, swapped into the panel. Never re-render the list — that is what loses scroll position and selection |
| PATCH /vendors/:id | the updated detail fragment, and the one changed row, so the list stays truthful without a reload (htmx: an out-of-band swap) |
| 404 | the record vanished under the user — replace the panel with an empty state that says so and clears the selection |
No AI-manages/human-monitors split here, stated rather than left silent: this is a human browsing and correcting master data one record at a time, not oversight of something automated running behind the list. There is no monitoring signal to name.
States
| State | What ships |
|---|---|
| Nothing selected | the panel holds a one-line prompt, not a blank box — this is the screen's first impression |
| Loading the detail | aria-busy on the panel only; the list stays interactive so the user can pick a different row |
| Empty list | the DIFFERENT empty when filters exclude everything: say which filter, and offer to clear it |
| Detail 404 | panel becomes an empty state; the row is deselected rather than left highlighted pointing at nothing |
| Save conflict | 409 in the panel with both values — see concurrency; nothing auto-merges |
| No permission to edit | the panel reads; the Edit control is absent, not disabled |
| Narrow screen | the panel becomes a drawer over the list — capped at 85vw, so a sliver of the list stays visible and the user keeps their place; selection survives closing it |
| No JS | the primary side-panel view (facts, badges) is plain markup and reads fully without JS. The drawer and edit-dialog variants depend on initDialogs()'s click delegation to call the native showModal() — without it, neither <dialog> ever opens, so "Open the detail as a drawer" and "Edit vendor V-1001" do nothing and those two views are unreachable |
Components used
complexity 2 of 4