busy-office-ui
Menu

Data table

Check a row — the bulk-action toolbar appears via :has(), zero JS. Sticky header, tabular numerals, density-aware row heights, container-query auto-compaction below 480px container width. Two tiers, same markup family: Simple (below) covers selection/sort/filter for free; reach for Advanced (data-grid-nav + initDataGrid(), further down this page) only when a screen genuinely needs full two-axis keyboard navigation — most tables don't. Not for laying out a page — a table means "these rows are the same kind of thing, and these columns are the same fact about each". Using one to place a form's labels beside its controls, or to put two panels side by side, tells a screen reader there is tabular data to navigate when there is none. Those want form sections and the layout primitives. A single record's own facts are not a table either — that is key-value facts, which is one thing described, not many things compared.

Simple — select, sort, filter

Vendor Cost center Amount Status
INV-10234 Acme Supply Co. CC-4021 $4,208.00 Pending
INV-10235 Globex Industrial CC-4021 $18,940.50 Approved
INV-10236 Initech GmbH CC-1180 $730.25 Rejected
INV-10237 Umbrella Logistics CC-2205 $2,110.00 Pending
INV-10238 Stark Components CC-1180 $56,000.00 Approved

A number derived from the selection — a total, a document count — is yours to compute, and the framework already says how to announce it. .bo-data-table__selection-count is the built-in case ("3 selected"). For anything else — "Create 2 purchase orders · $44,560.00", "Release 14 invoices" — compute it in your own change listener and write ONE summary into a visually-hidden aria-live="polite" element beside the table. Not aria-live on the number itself: sums recompute on every keystroke, so the cell would speak on each one. That is the same contract initTableSum documents for column totals, with a working implementation on editable grid — and data-sum-of deliberately sums every body row, not just the checked ones, because which rows count is a business rule and those stay yours.

Narrow container → auto-compaction

Invoice # Vendor Cost center Amount
INV-10234 Acme Supply Co. CC-4021 $4,208.00
INV-10235 Globex Industrial CC-4021 $18,940.50
INV-10236 Initech GmbH CC-1180 $730.25

The cost-center column hides itself; rows tighten — driven by the container's width, not the viewport. Reflow requirement (WCAG 1.4.10): data hidden by compaction must stay reachable — give rows a detail view (like the PO # link pattern) or don't mark columns --secondary. This sentence is the canonical statement of the rule; other pages point here (2026-08-21 sweep — it had been restated four ways).

The full priority ladder — --tertiary drops first

Invoice # Vendor Cost center Created by Amount
INV-10234 Acme Supply Co. CC-4021 J. Kim $4,208.00
INV-10235 Globex Industrial CC-4021 J. Kim $18,940.50
INV-10236 Initech GmbH CC-1180 J. Kim $730.25

Three tiers by what a narrowing container drops: unmarked columns are the ones the row cannot be understood without; --tertiary (nice-to-have context) drops first, below 40rem of container width; --secondary follows below 30rem. Resize this box's edge to watch "Created by" go before "Cost center". Same floors as the references (Fiori marks columns by importance, PatternFly stacks) with no JS and no new mechanism — one class on the cells, the existing named container query does the rest. Recovery is the reflow rule above — every tier equally: data a tier drops must stay reachable through the row's detail view. The column chooser does NOT restore a priority-hidden column, on purpose: it hides via [hidden], which composes one-way with the container query — the user's choice operates within what the space allows, and a chooser that could force a column into a container with no room for it would just recreate the sideways scroll the ladder exists to prevent.

Editing, toolbars and grid navigation

Two opt-in layers live on their own pages so this one stays the table itself: inline editing (seamless cell inputs, dirty state, Save/Cancel and the events you persist with) and toolbar, columns & grid navigation (column visibility, export, and the roving keyboard grid model).

The assembled screen lives in Patterns

Toolbar + sticky column + footer pagination + filter bar composed into a real invoice list — with its anatomy, data contract, states, and keyboard walkthrough — is the list-report pattern. This page documents the table itself; the pattern documents the screen.

Loading & error rows

Invoice #Amount
INV-10234$4,208.00
INV-10235$18,940.50

data-loading="true" (or aria-busy) dims the table and blocks interaction mid-swap — flip it from your fetch/HTMX code. For first-load, prefer skeleton rows.

Invoice #ProblemAmount
INV-10240—$1,120.00
INV-10241Duplicate already posted as INV-10198$5,300.00
INV-10242—$980.00

data-row-state="error" — the tint plus a start border, and the badge/message in the row carries the reason (two-channel: never the color alone). This is the visual channel the dirty row state above reuses in amber. Which marker, and when — the guideline for choosing between the row states, a cell tone, and no marker at all.

Performance at scale — measured, not guessed

Measured 2026-08-15 against the full table stack (sticky header, row checkboxes, badges, container query, initDataTables()) in the examples/po-app stress harness (/stress?n=…):

These are one machine's milliseconds, and the machine is not recorded — so read the shape (select-all cheap, the style flush the real cost) rather than the absolute figures. The harness is kept precisely so you can get figures for your hardware; a wall-clock millisecond published without its machine is not a number anyone else can reproduce.

A 2026-09 re-run was published here and is withdrawn. It reported select-all at 3 ms / 1k and 7 ms / 5k and concluded “not slower”. It was timing a control that was not wired: the stress harness gets its behaviours from the reference app's shared page template, and from 2026-08-23 to 2026-09-07 a trailing comment on that template's init line swallowed initDataTables(), so the select-all checkbox toggled itself and selected no rows. The figures in the table below predate that break and are unaffected; the re-run is simply not evidence of anything. It is called out rather than deleted because the lesson is the reusable part — a timing probe needs a control that proves the work happened (here: count the rows that ended up selected), and the probe that produced those two numbers was not kept, so nobody could re-run it to find out.

One is kept now. npm run measure:stress -w docs boots the reference app and re-measures against /stress from a clean clone. It reports the rows it actually selected next to every timing and exits non-zero when those disagree, so it cannot produce the withdrawn numbers above; it records the machine, the Chrome build and the CPU throttle factor; and it keeps the style flush in its own column rather than folding it into select-all. Its render figures are comparable across its own runs and to nothing else — see the withdrawal directly below.

The Initial render column that stood in the table below is withdrawn (2026-09-08) — it read 85 ms at 1k, 174 ms at 5k, 558 ms at 20k, and 1,625 ms and 3,783 ms on the two throttled rows. Its measurement window was never written down and is not recoverable: neither the commit that published it nor the review that added the throttled rows records one, no probe was kept beside it, and the single surviving sentence about how it was taken is a diagnostic aside, not a definition. The shape does not survive the missing method either, which is why the caveat above was not enough on its own: re-measured with the kept probe on a recorded machine, the defined render window traces a 1 : 5.8 : 11.5 curve across those same three row counts against the withdrawn column's 1 : 2.0 : 6.6. That does not show the old figures were wrong — it shows the disagreement cannot be diagnosed, because the two windows and the two machines both differ and no method was recorded to tell them apart. The interaction columns are kept, read as one sitting's shape rather than a constant. On an Apple M4 with Chrome 153 (1440px viewport), two sittings of seven runs put select-all at 0.7 / 3.3 / 13.6 ms and the style flush at 9.9 / 46.4 / 185.8 ms across the three row counts, so the flush costs about 14 times select-all, close to the published table's 12–13. Run against the 2026-08-15 stylesheet the table was measured on, it matches at 12–13; today's costs 5–11% more. The ratio depends on the browser build: on Chrome 141 the same M4 puts the flush at 28–31 times select-all, and a recorded four-core Xeon on Chrome 141 at 24–38. So the style flush is the cost to watch, and its size depends on the machine and the Chrome version. Measure yours with npm run measure:stress -w docs -- --rows 1000,5000,20000 --repeat 7 (add --throttle 4 for the throttled rows).

RowsSelect-allPost-bulk-check style flush
1,0004 msnegligible
5,00018 ms231 ms
20,00049 ms610 ms
5,000 4× CPU throttle15 ms174 ms
20,000 4× CPU throttle60 ms686 ms

The 4×-throttle rows were added after the decisions grill challenged the single-machine evidence. They read about the same as the unthrottled rows, where a working 4× throttle multiplies both columns by about 4 on the M4, so they are a separate, unrecorded sitting rather than a throttle of the one above. Nothing steps: cost grows with row count and no cliff showed up in any sitting. The recurring cost is the bulk-select style recalculation (the selected-row tint re-evaluating per row) — it repeats on EVERY bulk toggle, and it reaches about 0.8 s at 20k rows under a 4× throttle on the M4 with today's stylesheet. The genuine pain point is initial render at 20k rows on slow hardware, and with the column above withdrawn the figure to quote is the probe's own: a defined render window of ~4.1 s at 20k on a recorded four-core container, larger there than every other cost measured beside it, and re-derivable with npm run measure:stress -w docs -- --rows 20000. Practical guidance: a few thousand rows are fine as-is; beyond that, paginate server-side (page numbers or load-more) — which dense ERP screens want anyway for findability. There are no virtualization hooks: windowing breaks Ctrl-F, select-all semantics, and screen-reader row counts, and pagination is the answer at the scale where tables stop being scannable. Re-open condition, not dogma: if a real deployment measures interaction costs breaching ~1 s at row counts it genuinely cannot paginate, the /stress harness in examples/po-app is kept for exactly that re-measurement.

Wide tables — 50 columns

The counterpart to the row counts above: an ERP line table that grew sideways. Ten rows, fifty columns, first column frozen with .bo-data-table--sticky-col. Scroll it horizontally — the Item column stays put, the header stays put, and both stay opaque over the cells passing underneath.

Purchase order lines, 50 columns
ItemDescriptionVendorVendor partUoMQtyUnit priceCurrencyLine totalTax codeTax %Discount %NetCost centerGL accountProjectWBSPlantStorage locWarehouseBinBatchSerialLead timeRequestedConfirmedDeliveredGoods receiptInvoicePayment termsIncotermShip-toBill-toBuyerRequesterApproverApproved onStatusBlockedReasonContractAgreementPrice listConditionRebateFreightDutyOriginHS codeLast changed
LINE-001DES-11VEN-12VEN-13UOM-14QTY-15UNI-16CUR-17LIN-18TAX-19TAX-110DIS-111NET-112COS-113GL -114PRO-115WBS-116PLA-117STO-118WAR-119BIN-120BAT-121SER-122LEA-123REQ-124CON-125DEL-126GOO-127INV-128PAY-129INC-130SHI-131BIL-132BUY-133REQ-134APP-135APP-136STA-137BLO-138REA-139CON-140AGR-141PRI-142CON-143REB-144FRE-145DUT-146ORI-147HS -148LAS-149
LINE-002DES-21VEN-22VEN-23UOM-24QTY-25UNI-26CUR-27LIN-28TAX-29TAX-210DIS-211NET-212COS-213GL -214PRO-215WBS-216PLA-217STO-218WAR-219BIN-220BAT-221SER-222LEA-223REQ-224CON-225DEL-226GOO-227INV-228PAY-229INC-230SHI-231BIL-232BUY-233REQ-234APP-235APP-236STA-237BLO-238REA-239CON-240AGR-241PRI-242CON-243REB-244FRE-245DUT-246ORI-247HS -248LAS-249
LINE-003DES-31VEN-32VEN-33UOM-34QTY-35UNI-36CUR-37LIN-38TAX-39TAX-310DIS-311NET-312COS-313GL -314PRO-315WBS-316PLA-317STO-318WAR-319BIN-320BAT-321SER-322LEA-323REQ-324CON-325DEL-326GOO-327INV-328PAY-329INC-330SHI-331BIL-332BUY-333REQ-334APP-335APP-336STA-337BLO-338REA-339CON-340AGR-341PRI-342CON-343REB-344FRE-345DUT-346ORI-347HS -348LAS-349
LINE-004DES-41VEN-42VEN-43UOM-44QTY-45UNI-46CUR-47LIN-48TAX-49TAX-410DIS-411NET-412COS-413GL -414PRO-415WBS-416PLA-417STO-418WAR-419BIN-420BAT-421SER-422LEA-423REQ-424CON-425DEL-426GOO-427INV-428PAY-429INC-430SHI-431BIL-432BUY-433REQ-434APP-435APP-436STA-437BLO-438REA-439CON-440AGR-441PRI-442CON-443REB-444FRE-445DUT-446ORI-447HS -448LAS-449
LINE-005DES-51VEN-52VEN-53UOM-54QTY-55UNI-56CUR-57LIN-58TAX-59TAX-510DIS-511NET-512COS-513GL -514PRO-515WBS-516PLA-517STO-518WAR-519BIN-520BAT-521SER-522LEA-523REQ-524CON-525DEL-526GOO-527INV-528PAY-529INC-530SHI-531BIL-532BUY-533REQ-534APP-535APP-536STA-537BLO-538REA-539CON-540AGR-541PRI-542CON-543REB-544FRE-545DUT-546ORI-547HS -548LAS-549
LINE-006DES-61VEN-62VEN-63UOM-64QTY-65UNI-66CUR-67LIN-68TAX-69TAX-610DIS-611NET-612COS-613GL -614PRO-615WBS-616PLA-617STO-618WAR-619BIN-620BAT-621SER-622LEA-623REQ-624CON-625DEL-626GOO-627INV-628PAY-629INC-630SHI-631BIL-632BUY-633REQ-634APP-635APP-636STA-637BLO-638REA-639CON-640AGR-641PRI-642CON-643REB-644FRE-645DUT-646ORI-647HS -648LAS-649
LINE-007DES-71VEN-72VEN-73UOM-74QTY-75UNI-76CUR-77LIN-78TAX-79TAX-710DIS-711NET-712COS-713GL -714PRO-715WBS-716PLA-717STO-718WAR-719BIN-720BAT-721SER-722LEA-723REQ-724CON-725DEL-726GOO-727INV-728PAY-729INC-730SHI-731BIL-732BUY-733REQ-734APP-735APP-736STA-737BLO-738REA-739CON-740AGR-741PRI-742CON-743REB-744FRE-745DUT-746ORI-747HS -748LAS-749
LINE-008DES-81VEN-82VEN-83UOM-84QTY-85UNI-86CUR-87LIN-88TAX-89TAX-810DIS-811NET-812COS-813GL -814PRO-815WBS-816PLA-817STO-818WAR-819BIN-820BAT-821SER-822LEA-823REQ-824CON-825DEL-826GOO-827INV-828PAY-829INC-830SHI-831BIL-832BUY-833REQ-834APP-835APP-836STA-837BLO-838REA-839CON-840AGR-841PRI-842CON-843REB-844FRE-845DUT-846ORI-847HS -848LAS-849
LINE-009DES-91VEN-92VEN-93UOM-94QTY-95UNI-96CUR-97LIN-98TAX-99TAX-910DIS-911NET-912COS-913GL -914PRO-915WBS-916PLA-917STO-918WAR-919BIN-920BAT-921SER-922LEA-923REQ-924CON-925DEL-926GOO-927INV-928PAY-929INC-930SHI-931BIL-932BUY-933REQ-934APP-935APP-936STA-937BLO-938REA-939CON-940AGR-941PRI-942CON-943REB-944FRE-945DUT-946ORI-947HS -948LAS-949
LINE-010DES-101VEN-102VEN-103UOM-104QTY-105UNI-106CUR-107LIN-108TAX-109TAX-1010DIS-1011NET-1012COS-1013GL -1014PRO-1015WBS-1016PLA-1017STO-1018WAR-1019BIN-1020BAT-1021SER-1022LEA-1023REQ-1024CON-1025DEL-1026GOO-1027INV-1028PAY-1029INC-1030SHI-1031BIL-1032BUY-1033REQ-1034APP-1035APP-1036STA-1037BLO-1038REA-1039CON-1040AGR-1041PRI-1042CON-1043REB-1044FRE-1045DUT-1046ORI-1047HS -1048LAS-1049

The scroll container is the focusable element (tabindex="0" with an aria-label), so a keyboard user is not stranded behind fifty tab stops: focus the region and the arrow keys scroll it. Measured, because "reachable" is not the same as "usable": each press moves about 40px, so the fiftieth column is roughly 76 presses away, and End does not help — this container scrolls horizontally only, and End acts on the vertical axis. Reachable, then, but tedious by any honest reading, which is the next argument for the column chooser below rather than a defect to paper over.

Fifty visible columns is usually a symptom, and this page will say so rather than just prove it renders. Nobody reads fifty columns; they read the twelve their job needs. The ERP answer is a column chooser plus saved views — let each role pick its columns once and store the choice, so Purchasing sees prices and the warehouse sees bins. Ship the wide table as the source of those views, never as the default screen. If you genuinely need all fifty at once, that is a report or an export, not a screen — see the spreadsheet round-trip.

Multiple frozen columns

--sticky-col above freezes ONE column for free — its offset is always 0, no JS needed. Freezing a second or third column needs each earlier column's actual rendered width, which nothing knows in advance (columns size to content by default) — so this is the one case in this page's table story that opts into JS: data-sticky-cols="2" plus initStickyCols(), which measures the header row and keeps the offsets live across resizes. Capped at 3 columns — freezing more than that is usually a sign the table needs a column chooser, not more frozen columns.

Purchase order lines, first two columns frozen
ItemDescriptionVendorVendor partUoMQtyUnit priceCurrencyLine totalTax codeTax %Discount %NetCost centerGL accountProjectWBSPlantStorage locWarehouseBinBatchSerialLead timeRequestedConfirmedDeliveredGoods receiptInvoicePayment termsIncotermShip-toBill-toBuyerRequesterApproverApproved onStatusBlockedReasonContractAgreementPrice listConditionRebateFreightDutyOriginHS codeLast changed
LINE-001DES-11VEN-12VEN-13UOM-14QTY-15UNI-16CUR-17LIN-18TAX-19TAX-110DIS-111NET-112COS-113GL -114PRO-115WBS-116PLA-117STO-118WAR-119BIN-120BAT-121SER-122LEA-123REQ-124CON-125DEL-126GOO-127INV-128PAY-129INC-130SHI-131BIL-132BUY-133REQ-134APP-135APP-136STA-137BLO-138REA-139CON-140AGR-141PRI-142CON-143REB-144FRE-145DUT-146ORI-147HS -148LAS-149
LINE-002DES-21VEN-22VEN-23UOM-24QTY-25UNI-26CUR-27LIN-28TAX-29TAX-210DIS-211NET-212COS-213GL -214PRO-215WBS-216PLA-217STO-218WAR-219BIN-220BAT-221SER-222LEA-223REQ-224CON-225DEL-226GOO-227INV-228PAY-229INC-230SHI-231BIL-232BUY-233REQ-234APP-235APP-236STA-237BLO-238REA-239CON-240AGR-241PRI-242CON-243REB-244FRE-245DUT-246ORI-247HS -248LAS-249
LINE-003DES-31VEN-32VEN-33UOM-34QTY-35UNI-36CUR-37LIN-38TAX-39TAX-310DIS-311NET-312COS-313GL -314PRO-315WBS-316PLA-317STO-318WAR-319BIN-320BAT-321SER-322LEA-323REQ-324CON-325DEL-326GOO-327INV-328PAY-329INC-330SHI-331BIL-332BUY-333REQ-334APP-335APP-336STA-337BLO-338REA-339CON-340AGR-341PRI-342CON-343REB-344FRE-345DUT-346ORI-347HS -348LAS-349
LINE-004DES-41VEN-42VEN-43UOM-44QTY-45UNI-46CUR-47LIN-48TAX-49TAX-410DIS-411NET-412COS-413GL -414PRO-415WBS-416PLA-417STO-418WAR-419BIN-420BAT-421SER-422LEA-423REQ-424CON-425DEL-426GOO-427INV-428PAY-429INC-430SHI-431BIL-432BUY-433REQ-434APP-435APP-436STA-437BLO-438REA-439CON-440AGR-441PRI-442CON-443REB-444FRE-445DUT-446ORI-447HS -448LAS-449
LINE-005DES-51VEN-52VEN-53UOM-54QTY-55UNI-56CUR-57LIN-58TAX-59TAX-510DIS-511NET-512COS-513GL -514PRO-515WBS-516PLA-517STO-518WAR-519BIN-520BAT-521SER-522LEA-523REQ-524CON-525DEL-526GOO-527INV-528PAY-529INC-530SHI-531BIL-532BUY-533REQ-534APP-535APP-536STA-537BLO-538REA-539CON-540AGR-541PRI-542CON-543REB-544FRE-545DUT-546ORI-547HS -548LAS-549
LINE-006DES-61VEN-62VEN-63UOM-64QTY-65UNI-66CUR-67LIN-68TAX-69TAX-610DIS-611NET-612COS-613GL -614PRO-615WBS-616PLA-617STO-618WAR-619BIN-620BAT-621SER-622LEA-623REQ-624CON-625DEL-626GOO-627INV-628PAY-629INC-630SHI-631BIL-632BUY-633REQ-634APP-635APP-636STA-637BLO-638REA-639CON-640AGR-641PRI-642CON-643REB-644FRE-645DUT-646ORI-647HS -648LAS-649
LINE-007DES-71VEN-72VEN-73UOM-74QTY-75UNI-76CUR-77LIN-78TAX-79TAX-710DIS-711NET-712COS-713GL -714PRO-715WBS-716PLA-717STO-718WAR-719BIN-720BAT-721SER-722LEA-723REQ-724CON-725DEL-726GOO-727INV-728PAY-729INC-730SHI-731BIL-732BUY-733REQ-734APP-735APP-736STA-737BLO-738REA-739CON-740AGR-741PRI-742CON-743REB-744FRE-745DUT-746ORI-747HS -748LAS-749
LINE-008DES-81VEN-82VEN-83UOM-84QTY-85UNI-86CUR-87LIN-88TAX-89TAX-810DIS-811NET-812COS-813GL -814PRO-815WBS-816PLA-817STO-818WAR-819BIN-820BAT-821SER-822LEA-823REQ-824CON-825DEL-826GOO-827INV-828PAY-829INC-830SHI-831BIL-832BUY-833REQ-834APP-835APP-836STA-837BLO-838REA-839CON-840AGR-841PRI-842CON-843REB-844FRE-845DUT-846ORI-847HS -848LAS-849
LINE-009DES-91VEN-92VEN-93UOM-94QTY-95UNI-96CUR-97LIN-98TAX-99TAX-910DIS-911NET-912COS-913GL -914PRO-915WBS-916PLA-917STO-918WAR-919BIN-920BAT-921SER-922LEA-923REQ-924CON-925DEL-926GOO-927INV-928PAY-929INC-930SHI-931BIL-932BUY-933REQ-934APP-935APP-936STA-937BLO-938REA-939CON-940AGR-941PRI-942CON-943REB-944FRE-945DUT-946ORI-947HS -948LAS-949
LINE-010DES-101VEN-102VEN-103UOM-104QTY-105UNI-106CUR-107LIN-108TAX-109TAX-1010DIS-1011NET-1012COS-1013GL -1014PRO-1015WBS-1016PLA-1017STO-1018WAR-1019BIN-1020BAT-1021SER-1022LEA-1023REQ-1024CON-1025DEL-1026GOO-1027INV-1028PAY-1029INC-1030SHI-1031BIL-1032BUY-1033REQ-1034APP-1035APP-1036STA-1037BLO-1038REA-1039CON-1040AGR-1041PRI-1042CON-1043REB-1044FRE-1045DUT-1046ORI-1047HS -1048LAS-1049

Scroll horizontally — Line and the second column both stay put now, not just the first. Both frozen columns and the header stay opaque over the cells passing underneath, same as the single-column case.

Right-click column menu

data-context-menu opens the SAME popover menu component initDropdowns() already renders for the toolbar's Columns picker — right-click a header instead of clicking a button, and the menu opens at the cursor. This behavior (initContextMenu()) owns exactly one thing: positioning. It doesn't own sorting or hiding — those menu items below call the exact mechanisms this page already documents (aria-sort for sort, data-col-toggle for hide), the same as clicking the existing sort button or toolbar checkbox would. A per-column filter item and a runtime "always visible" pin were graded out of scope for the framework — see .roundtable/ for the reasoning.

Acme Supply Co.$4,208.00
Globex Industrial$18,940.50


Right-click "Vendor" or "Amount" above. The Sort items are static here, the same way the "Simple" section's sort button at the top of this page is — wiring them to actually re-order rows is consumer code either way, on-page click or context-menu click makes no difference to that contract. "Show column" is real: uncheck it and the column disappears immediately, because it's the exact same data-col-toggle checkbox the toolbar pattern uses, just rendered inside a right-click menu instead of a toolbar dropdown — the menu's own markup has to live INSIDE the same .bo-data-table-container as the table for that to work (data-col-toggle's hide logic looks up its container via .closest(); a popover placed as a sibling after the container, which is where it would naturally land if authored after the closing <table> tag, silently finds no container and does nothing — caught live on this exact demo before it shipped).

A column that explains itself

ERP headers are full of terms a newcomer cannot resolve — GR/IR, FSV, CR/INC/PR, "Dep date". Give such a column a description and it stops being folklore. Only columns that need one get the button: a marker on all twelve headers is clutter, and most columns say what they are.

A title tooltip is the wrong answer, which is why this composes the popover instead. title is invisible on touch, unreachable by keyboard, and inconsistently surfaced by screen readers — the explanation would exist only for a mouse user who happened to hover. The button is a real <button> and the panel is the same native [popover] dropdown uses, so Esc and light dismiss are free. It also inherits the top-layer behaviour dropdown documents and proves — a panel that is never clipped by a scrolling table container — which is the part that is genuinely hard to get right, and the reason to compose that component rather than position a div by hand.

Goods receipts, with an explained column
Document GR/IR

Goods-receipt / invoice-receipt clearing: the balance between what arrived and what was invoiced. A non-zero value means a delivery and its invoice disagree.

Balance
GR-88104Open1,240.00
GR-88102Cleared0.00

The description lives in the header cell, so it travels with the column — nothing to keep in sync elsewhere. Keep it to a sentence or two: a paragraph in a popover is a document that has escaped, and it belongs on a concept page you link to instead.

Grouped rows + subtotals

PO # Vendor Amount
CC-1180 — Facilities
PO-88212Initech GmbH$730.25
PO-88214Stark Components$56,000.00
Subtotal CC-1180$56,730.25
CC-4021 — Warehouse
PO-88210Acme Supply Co.$4,208.00
Subtotal CC-4021$4,208.00
Grand total$60,938.25

No grouping component exists because none is needed — this composes from plain HTML table semantics the CSS already styles: one <tbody> per group, the group header a <th scope="rowgroup" colspan> row (bold by default; the scope declares it heads its <tbody> — screen-reader support for rowgroup association varies, so keep group names ALSO in prose/ captions when the grouping is load-bearing), subtotal and grand-total rows are ordinary rows using __col--right/__col--numeric. Proven in the examples/po-app "Spend by cost center" screen, both themes. Don't combine with --striped (stripes land arbitrarily on summary rows) or with row-select/sort (sorting across grouped bodies is your code's problem — regroup server-side instead).

Opening the record a row names

Open task queue
TaskStatus
PO-88213 · Dock 4Next
SO-51204 · PickQueued
CC-3092 · CountQueued

Every ERP list has a row that opens something. Put .bo-data-table__cell-link on the link in the identifying cell and it becomes a full-cell target instead of a strip of text. Not for a row with several actions — one cell link per row, or the reader cannot tell which part opens what; put multiple actions in their own cell as buttons, the way inbox does.

Why it exists, measured rather than assumed: a plain <a> in a cell is inline, so its hit area is its line box — 18px tall at spacious density for a label that fits on one line. That still conforms to WCAG 2.5.8 — rows this far apart pass via the spacing exception, the same way this framework's 18px sort button does — which is exactly why conformance is the wrong bar here. The RF track's bar is a gloved thumb, and 18px is not that. It is easy to miss because a label that wraps measures 42px and looks fine. The part takes the cell's own vertical padding instead of sitting inside it, which lifts the same link to 48px without moving anything on screen. Styling the link as a button also works and costs more than it looks: the padding pushes the next column 44px past a 320px container.

Grouped column headers

Three-way match: quantity and unit price, ordered against received against billed
Item Quantity Unit price
Ordered Received Billed Ordered Billed
Hex bolt M8 × 402,4002,4002,4000.420.42
Hex nut M82,4002,4002,4000.180.18
Steel plate 3mm606060412.00424.00
Pallet, heat-treated1208012018.0018.00
Washer M8, zinc4,8004,8004,8000.040.04
Threadlocker, 50ml2424249.609.60
Anchor bolt M126006006001.851.85
Gasket sheet, 2mm15151562.0062.00

Three readings of one line — ordered, received, billed — belong under one heading, or the reader has to parse "Qty ordered / Qty received / Qty billed" five times per row. Plain table semantics do all of it: <th scope="colgroup" colspan> on the group, scope="col" on each column beneath it, and rowspan="2" for a column that has no group. No class, no modifier — a second header row simply works. Not for making one wide table narrower: grouping headers does not reduce how much a reader must hold in their head, so if the columns are unrelated, split the table or use tabs instead.

What the framework does for you is the part that silently breaks: every header cell is position: sticky, so without help both rows pin to the same offset and the second lands on top of the first — measured on a real screen, the group label was not merely overlapped but completely invisible. Each row now offsets by --bo-density-row-height, which is the same token that sets the row height, so it stays exact at every density. Up to three header rows; beyond that the table wants splitting, not more CSS. Keep group labels short — a label that wraps to two lines exceeds the token and sits a few pixels off.

Conditional cell tone

data-tone="danger|warning|success" on a <td> tints that one cell — a subtle background, and no leading edge: the edge belongs to the row, and only to the row (see the guideline below). The framework never decides why a cell is toned: a negative balance, a budget threshold, any business rule — that condition is computed server-side and rendered as this one attribute. Pair the tone with text that says the same thing (a sign, a word, a badge) — the color is not the only channel. Background is the default; add data-tone-text to also tint the text (the CC-1180 row below) — opt-in rather than automatic, since forcing text color on every toned cell would fight a value that's already bold or already colored by something else.

Cost centre Budget Balance
CC-1180 — Facilities $60,000.00 −$730.25
CC-4021 — Warehouse $5,000.00 $4,780.00
CC-2200 — IT $12,000.00 $3,120.00

The minus sign on the first Balance cell carries the "over budget" meaning in text, same as the badges elsewhere on this page carry status in text — data-tone only repeats it visually. Composes with row state: a cell's own --bo-cell-bg wins over its row's, so a toned cell inside a dirty or striped row still reads correctly.

Which marker, and when — the guideline

A row can be dirty, in error or in warning; a cell can be toned. They are not interchangeable and they do not stack. Choose by what is true of the data, not by how much attention you want.

Marker What it means Reach for it when
tr[data-row-state="error"]
tint + leading edge
This row is blocked. Something has to change before it can be saved, posted or approved. The server rejected it, a validation failed, a duplicate was found — always with the reason written in the row.
tr[data-row-state="warning"]
tint + leading edge
This row will go through, and you should look first. Nothing is blocked. A tolerance breach, a short receipt, a price that moved since the order was placed.
tr[data-row-state="dirty"]
tint + leading edge
This row has unsaved edits. A state of the editor, not of the record. Set by initRowEdit(), not by hand — see inline editing.
td[data-tone]
tint only, no edge
This value crossed a line your rules drew. The row itself is fine. Over budget, below reorder point, outside tolerance. It marks the cell, and never implies anything about the row.
No marker Nothing is wrong. The default, and the one most often got wrong — see below.

One row shows at most one leading edge

The edge means the ROW is in a state, and nothing else. A cell tone is never an edge — it was until 2026-08-27, and the two were indistinguishable: a red edge on an amount cell read exactly like the marker for a failed row beside it. A dirty row holding an over-budget cell shows one edge, the row's, plus a tint on that cell. Wanting a second edge means the row is being asked to say two things, and one of them belongs in a badge or a message.

The states are mutually exclusive by construction — one attribute, one value — so a row that is both rejected and being edited has to pick. It shows dirty while the user types, because that is the state they are creating; when the row goes clean, initRowEdit() puts error back if a cell is still aria-invalid. Cancel restores the values the row arrived with, which for a rejected row are still the rejected ones, so the error survives a Cancel rather than being tidied away by it.

When no marker is right

A value that is merely negative, small or zero is not an error. A credit note, a negative variance, a zero balance are ordinary data, and Amount's --negative modifier colours the figure without claiming anything about the row. Mark those and every screen opens half-red, which spends the one signal you have on rows nobody needs to look at. Ask what the reader must do: if the answer is nothing, use no marker.

Tone fails the same way when most rows qualify — the tint is then describing a ranking, not an exception. Sort by the value, or put the meaning in a badge the reader can actually read.

Why the edge exists at all

It is not decoration. Under forced-colors (Windows High Contrast) the browser throws away background colours, so a state carried by tint alone would vanish for exactly the readers who most need it — the edge survives, because the forced-colors rule swaps it for a real border-inline-start. It follows the leading edge: under dir="rtl" it flips to the right, one of the framework's six flip sites (see i18n).

A toned cell has no such fallback, which is why colour is never its only channel. The meaning belongs in adjacent text — a sign, a word, a badge, a visually-hidden phrase, as the demo above does with its minus sign — because under high contrast that text is all that is left. On a right-aligned number, data-tone-text puts the tone on the digits themselves.

Column alignment

Put the same modifier on the <th> and every <td> in a column: --left, --center, --right, or --numeric (right-aligned and tabular figures, for amounts).

Item (left) Status (center) Weight (right) Amount (numeric)
Standing desk Approved 12 kg 1,208.00
Monitor arm Pending 3 kg 92.50

Alignment is a single class per cell and wins over the default (start) reliably — headers and body cells align together.

Column width & long content

Columns already size themselves to their content — the table never sets table-layout: fixed, so the browser's default column-width algorithm does the "fit the contents" work with zero CSS from this framework. What that algorithm doesn't do on its own is stop a column growing forever, and the fix has a real trap in it: max-inline-size on the <td> itself is silently ignored under table-layout: auto unless the row is already under width pressure — a table with room to spare just renders the cell at its natural width, max-width or not. Put the cap on an inner element instead (a <span> is enough) alongside the existing .bo-u-text-truncate utility — plain CSS, not a data-table-specific mechanism.

PO # Vendor Amount
PO-88214 Stark Components International Holdings $56,000.00

The full vendor name is still in the DOM — this is a visual clip, not a data loss, so a screen reader and Ctrl+F still see the whole string. Give the inner element a title attribute if the clipped tail matters enough to show on hover. examples/po-app's own Vendor column uses the bare <td> form without a fixed cap — that only truncates once the whole table is genuinely squeezed for space, which is a different, weaker guarantee than this section's deliberate cap; worth fixing there too, tracked as a follow-up rather than folded in silently here.

Nowrap without clipping. Sometimes a column shouldn't wrap OR truncate — it should just stay on one line and let the table grow wider instead. .bo-u-text-nowrap is that: plain white-space: nowrap, no overflow/ text-overflow. Wrapping is already the free default (no class needed) — this utility only exists for the opposite case. Because white-space is inherited, the SAME class works at either granularity: on one <td> it's column-scoped, on the <table> itself every cell inherits it — no separate "by column" vs "by table" mechanism.

PO # Status detail
PO-88214 Awaiting Finance approval — escalated 2026-08-19

The whole table has .bo-u-text-nowrap on it, so every cell — not just Status detail — stays single-line; the container's own horizontal scroll (already there for every table on this page) takes over instead of the row growing tall.

Font styling (bold, emphasis) is the same story: no utility class exists or is planned for it — a subtotal or grand-total row uses a real <strong> (see "Grouped rows + subtotals" above), which is both simpler and correct HTML semantics a screen reader can act on. Adding a bo-u-font-bold-style class would be a second way to do something native HTML already does — this framework ships "semantic components, not utility soup" on purpose.

Markup — the canonical table recipe

<!-- tabindex="0": the container scrolls when the table
     overflows, and a scrollable region must be keyboard-reachable -->
<div class="bo-data-table-container" tabindex="0">
  <div class="bo-data-table__toolbar">
    <div class="bo-data-table__bulk-actions" role="group" aria-label="Bulk actions">
      <button class="bo-btn bo-btn--secondary" type="button">Approve</button>
    </div>
    <span class="bo-data-table__selection-count"></span> <!-- live "n selected" -->
    <input class="bo-input" type="search" aria-label="Filter invoices" />
  </div>
  <table class="bo-data-table">
    <thead><tr>
      <th scope="col"><input type="checkbox"
          class="bo-checkbox bo-data-table__select-all" aria-label="Select all" /></th>
      <th scope="col" aria-sort="ascending">
        <button class="bo-data-table__sort-btn" type="button">Invoice #</button>
      </th>
      <th scope="col" class="bo-data-table__col--numeric">Amount</th>
    </tr></thead>
    <tbody><tr>
      <td><input type="checkbox" name="id" value="INV-1"
          class="bo-checkbox bo-data-table__row-select" aria-label="Select INV-1" /></td>
      <!-- name/value make the selection POSTable. Prefer a real <form> around
           the rows AND the bulk buttons over hx-include on the button: both
           send the same ids, but only the form gives you Enter-from-any-row
           (native implicit submission). -->
      <td class="bo-data-table__col--code">INV-1</td>
      <td class="bo-data-table__col--numeric">$4,208.00</td>
    </tr></tbody>
  </table>
</div>
<script type="module">
  import { initDataTables } from "@busy-office/ui/js";
  initDataTables(); // select-all, live count, O(1) toolbar reveal
</script>
<!-- Sorting contract: YOUR code (server or client) re-orders rows and sets
     aria-sort on the <th>; the CSS draws the indicator from aria-sort. -->

Quick reference generated

ClassRole
.bo-data-table block (root)
.bo-data-table--sticky-col variant: sticky-col
.bo-data-table--striped variant: striped
.bo-data-table-container block (root)
.bo-data-table__bulk-actions part: bulk-actions
.bo-data-table__cell-link part: cell-link
.bo-data-table__col--center variant: center
.bo-data-table__col--code variant: code
.bo-data-table__col--left variant: left
.bo-data-table__col--numeric variant: numeric
.bo-data-table__col--right variant: right
.bo-data-table__col--secondary variant: secondary
.bo-data-table__col--tertiary variant: tertiary
.bo-data-table__footer part: footer
.bo-data-table__row-edit-actions part: row-edit-actions
.bo-data-table__row-select part: row-select
.bo-data-table__select-all part: select-all
.bo-data-table__selection-count part: selection-count
.bo-data-table__sort-btn part: sort-btn
.bo-data-table__spacer part: spacer
.bo-data-table__toolbar part: toolbar
.bo-form-field block (root)
.bo-form-field__message part: message

State attributes: data-any-selecteddata-densitydata-grid-navdata-loadingdata-row-statedata-sticky-colsdata-tonedata-tone-textaria-busyaria-sort

API generated from CSS

Blocks bo-data-tablebo-data-table-containerbo-form-field
Parts bo-data-table__bulk-actionsbo-data-table__cell-linkbo-data-table__footerbo-data-table__row-edit-actionsbo-data-table__row-selectbo-data-table__select-allbo-data-table__selection-countbo-data-table__sort-btnbo-data-table__spacerbo-data-table__toolbarbo-form-field__message
Variants bo-data-table--sticky-colbo-data-table--stripedbo-data-table__col--centerbo-data-table__col--codebo-data-table__col--leftbo-data-table__col--numericbo-data-table__col--rightbo-data-table__col--secondarybo-data-table__col--tertiary
data-* hooks data-any-selected="true"data-densitydata-grid-navdata-loading="true"data-row-state="dirty|error|warning"data-sticky-cols="2|3"data-tone="danger|success|warning"data-tone-text
ARIA styled aria-busyaria-sort
Component tokens --bo-cell-bg--bo-density-cell-padding-x--bo-density-cell-padding-y--bo-density-control-height--bo-density-field-gap--bo-density-font-size--bo-density-row-height
Named containers bo-table
JS required Optional but recommended: initDataTables() — select-all, live count, O(1) toolbar reveal, swap re-derive. Without it: selection visuals work via :has(); select-all is inert.
  • Sorting contract: the framework paints the indicator from aria-sort on the <th>; re-ordering rows and updating the attribute is YOUR code (server or client).
  • Row checkboxes need name/value to POST selections. Wrap the rows and the bulk buttons in one <form> rather than reaching for hx-include: only the form gives native implicit submission, so Enter from any row runs the bulk action instead of Shift-Tabbing back to a toolbar above the table. Make only the SAFE action type="submit" — implicit submission fires the first submit button, and that must never be a destructive one.
  • If the bulk swap replaces the rows, give each row checkbox an id: htmx restores focus after a swap only to a matching id, and without it the keyboard user lands on <body>.
  • Columns marked __col--secondary hide under container compaction — the reflow requirement (WCAG 1.4.10) above the auto-compaction demo is the rule.
  • Separately opt-in: data-grid-nav + initDataGrid() for the full WAI-ARIA APG Grid pattern (two-axis keyboard navigation). Off by default — a read-only reference table does not need per-cell Tab stops.
  • Separately opt-in: data-row-edit + initRowEdit() for multi-row inline edit with per-row dirty state and Save/Cancel. Tracks state only — persistence is your code, via the bo:row-save event.
  • Separately opt-in: data-col-toggle/data-table-export + initTableToolbar() for column visibility (composes with the multi-select dropdown, no new markup) and an export intent (bo:table-export — generating the file is your code). Rows appended later (load-more, swaps) don't inherit hidden columns — call initTableToolbar() again after appending, same re-call convention as initDataTables().
  • data-tone="danger|warning|success" on a <td> tints that cell (CSS-only, no JS). The framework paints the tone; deciding which cells get one — a negative balance, a threshold, any rule — is server-side logic, never the framework's. Add data-tone-text alongside it to also tint the text (opt-in, not automatic).
  • Separately opt-in: data-sticky-cols="2|3" + initStickyCols() freezes that many leading columns — for ONE, use --sticky-col instead (offset always 0, no JS; data-sticky-cols="1" is deliberately not supported, it would just be a second way to do the same thing). Two or three need JS because column widths aren't known until rendered — the behavior measures the header row and keeps offsets live via ResizeObserver.
  • Separately opt-in: data-context-menu="menu-id" + initContextMenu() opens a .bo-dropdown__menu at the cursor on right-click instead of at a button. It only positions the menu — the items inside call whatever this page already documents (an aria-sort control, a data-col-toggle checkbox); this behavior owns no sort or filter logic of its own.
Contrast — 33 AA pairs this component uses (generated, CI-gated)
TextonLightDark
text-primary bg-surface 17.74 16.15
text-primary bg-canvas 16.98 18.08
text-secondary bg-surface 10.31 11.46
text-secondary bg-muted 9.37 9.75
text-muted bg-surface 7.56 6.65
text-muted bg-muted 6.87 5.66
bg-surface text-primary 17.74 16.15
danger-text danger-subtle 5.91 8.06
warning-text warning-subtle 4.84 9.21
success-text success-subtle 4.79 10.17
danger-text bg-surface 6.47 8.89
danger-text bg-canvas 6.19 9.96
success-text bg-surface 5.02 12.02
success-text bg-canvas 4.8 13.46
accent bg-surface 5.47 9.07
text-primary bg-hover 16.12 13.75
text-primary bg-selected 17.01 11.86
text-secondary bg-selected 9.88 8.41
accent-text bg-selected 7.27 8.38
text-secondary bg-canvas 9.86 12.82
border-control bg-surface 4.83 6.65
accent bg-selected 5.25 6.66
--bo-state-error-color bg-surface 4.83 6.1
border-control bg-hover 4.39 5.66
border-control bg-muted 4.39 5.66
focus-ring bg-canvas 3.58 5.05
accent bg-muted 4.97 7.72
warning-strong bg-muted 4.56 6.69
warning-strong bg-surface 5.02 7.86
danger bg-muted 4.39 5.19
text-primary bg-muted 16.12 13.75
success-text bg-muted 4.56 10.23
danger-text bg-muted 5.88 7.57

Maturity generated

Introduced
v0.1.0
CSS floor
Chrome/Edge 117 · Firefox 129 · Safari 17.5
Alignment scored
2026-08-23
Assistive-tech evidence
None recorded

Pin this component's first release with npm i @busy-office/ui@0.1.0. The floor is derived from this component's stylesheet alone — set by @starting-style — so it sits at or below the whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5. No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour. What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see accessibility.

Design-system alignment generated

100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-23.

Rule Score Evidence
Typography 3 / 3 __col--code consumes --bo-font-size-mono-inline (94.13); the 0.03em letter-spacing is intrinsic, same basis as form/combobox
Colour 3 / 3 raw hex appears ONLY inside @media print, where theme tokens would be wrong on paper — checked in context, not a defect; screen pairs are check:contrast-gated
Spacing 3 / 3 94.3 moved the two auto-compaction heights OUT of this file — row and control height are set from --bo-density-auto-* in tokens/density.css, beside the tiers they relate to — so data-table.css carries no height literal at all (re-verified 2026-09-05: 0 height-family declarations carrying a unit literal, against 15 of 44 component stylesheets that carry one). What it carries is the reason (94.2), corrected by roadmap 281.1 after re-measuring it live: raising cell-padding-x to compact's --bo-space-2 at 390px grows a row whose cells WRAP by ~19-20px (68px to 87px on /components/dialog and /components/state-patterns, `a24ed45`), while a row that does not wrap is held at the row-height floor and goes 28px to 30px on the auto-vs-compact row height alone — padding plays no part in that half, and 94.3's single sentence read as though it did. So the tighter padding buys horizontal room, the scarce resource at phone width. 94.3's cited example page, /patterns/detail-form, no longer SHOWS the step — but it is still REACHED by the rule, and 281.1's stated cause is refuted (roadmap 286.1): `69a53364` (109.19) is a pure addition, 91 insertions and 0 deletions, that added a different table and never touched this one, which is unchanged since 94.3 (5 th, 3 tr, 26 lines) and still carries no data-density. What moved is the baseline, not the reach: at 390px the reached table measures 260px against its three siblings' 310px and its first row already wraps at 87px before any mutation, so it cannot produce a 68px -> 87px step. The example is now named by property, not by page.
Interaction 3 / 3 selection works via :has() with zero JS; behaviors are opt-in; focus restored across htmx swaps; menus are native popover
Content 3 / 3 opener carries the wrong-choice clause: "Not for laying out a page" — a table asserts same-kind rows and same-fact columns, so using one for form layout or side-by-side panels lies to a screen reader; points at form sections + layout primitives, and at kv for ONE record's facts. Blind re-scored 2026-08-23 (Polish round 6), judged genuine.
Fit 3 / 3 matrix table contexts correct incl. the split-column rule (88) and conditional tone; hover ring fixed 73.2
↑↓ move Enter open Esc close