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
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 # | Problem | Amount |
|---|---|---|
| INV-10240 | — | $1,120.00 |
| INV-10241 | Duplicate 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).
| Rows | Select-all | Post-bulk-check style flush |
|---|---|---|
| 1,000 | 4 ms | negligible |
| 5,000 | 18 ms | 231 ms |
| 20,000 | 49 ms | 610 ms |
| 5,000 4× CPU throttle | 15 ms | 174 ms |
| 20,000 4× CPU throttle | 60 ms | 686 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.
| Item | Description | Vendor | Vendor part | UoM | Qty | Unit price | Currency | Line total | Tax code | Tax % | Discount % | Net | Cost center | GL account | Project | WBS | Plant | Storage loc | Warehouse | Bin | Batch | Serial | Lead time | Requested | Confirmed | Delivered | Goods receipt | Invoice | Payment terms | Incoterm | Ship-to | Bill-to | Buyer | Requester | Approver | Approved on | Status | Blocked | Reason | Contract | Agreement | Price list | Condition | Rebate | Freight | Duty | Origin | HS code | Last changed |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| LINE-001 | DES-11 | VEN-12 | VEN-13 | UOM-14 | QTY-15 | UNI-16 | CUR-17 | LIN-18 | TAX-19 | TAX-110 | DIS-111 | NET-112 | COS-113 | GL -114 | PRO-115 | WBS-116 | PLA-117 | STO-118 | WAR-119 | BIN-120 | BAT-121 | SER-122 | LEA-123 | REQ-124 | CON-125 | DEL-126 | GOO-127 | INV-128 | PAY-129 | INC-130 | SHI-131 | BIL-132 | BUY-133 | REQ-134 | APP-135 | APP-136 | STA-137 | BLO-138 | REA-139 | CON-140 | AGR-141 | PRI-142 | CON-143 | REB-144 | FRE-145 | DUT-146 | ORI-147 | HS -148 | LAS-149 |
| LINE-002 | DES-21 | VEN-22 | VEN-23 | UOM-24 | QTY-25 | UNI-26 | CUR-27 | LIN-28 | TAX-29 | TAX-210 | DIS-211 | NET-212 | COS-213 | GL -214 | PRO-215 | WBS-216 | PLA-217 | STO-218 | WAR-219 | BIN-220 | BAT-221 | SER-222 | LEA-223 | REQ-224 | CON-225 | DEL-226 | GOO-227 | INV-228 | PAY-229 | INC-230 | SHI-231 | BIL-232 | BUY-233 | REQ-234 | APP-235 | APP-236 | STA-237 | BLO-238 | REA-239 | CON-240 | AGR-241 | PRI-242 | CON-243 | REB-244 | FRE-245 | DUT-246 | ORI-247 | HS -248 | LAS-249 |
| LINE-003 | DES-31 | VEN-32 | VEN-33 | UOM-34 | QTY-35 | UNI-36 | CUR-37 | LIN-38 | TAX-39 | TAX-310 | DIS-311 | NET-312 | COS-313 | GL -314 | PRO-315 | WBS-316 | PLA-317 | STO-318 | WAR-319 | BIN-320 | BAT-321 | SER-322 | LEA-323 | REQ-324 | CON-325 | DEL-326 | GOO-327 | INV-328 | PAY-329 | INC-330 | SHI-331 | BIL-332 | BUY-333 | REQ-334 | APP-335 | APP-336 | STA-337 | BLO-338 | REA-339 | CON-340 | AGR-341 | PRI-342 | CON-343 | REB-344 | FRE-345 | DUT-346 | ORI-347 | HS -348 | LAS-349 |
| LINE-004 | DES-41 | VEN-42 | VEN-43 | UOM-44 | QTY-45 | UNI-46 | CUR-47 | LIN-48 | TAX-49 | TAX-410 | DIS-411 | NET-412 | COS-413 | GL -414 | PRO-415 | WBS-416 | PLA-417 | STO-418 | WAR-419 | BIN-420 | BAT-421 | SER-422 | LEA-423 | REQ-424 | CON-425 | DEL-426 | GOO-427 | INV-428 | PAY-429 | INC-430 | SHI-431 | BIL-432 | BUY-433 | REQ-434 | APP-435 | APP-436 | STA-437 | BLO-438 | REA-439 | CON-440 | AGR-441 | PRI-442 | CON-443 | REB-444 | FRE-445 | DUT-446 | ORI-447 | HS -448 | LAS-449 |
| LINE-005 | DES-51 | VEN-52 | VEN-53 | UOM-54 | QTY-55 | UNI-56 | CUR-57 | LIN-58 | TAX-59 | TAX-510 | DIS-511 | NET-512 | COS-513 | GL -514 | PRO-515 | WBS-516 | PLA-517 | STO-518 | WAR-519 | BIN-520 | BAT-521 | SER-522 | LEA-523 | REQ-524 | CON-525 | DEL-526 | GOO-527 | INV-528 | PAY-529 | INC-530 | SHI-531 | BIL-532 | BUY-533 | REQ-534 | APP-535 | APP-536 | STA-537 | BLO-538 | REA-539 | CON-540 | AGR-541 | PRI-542 | CON-543 | REB-544 | FRE-545 | DUT-546 | ORI-547 | HS -548 | LAS-549 |
| LINE-006 | DES-61 | VEN-62 | VEN-63 | UOM-64 | QTY-65 | UNI-66 | CUR-67 | LIN-68 | TAX-69 | TAX-610 | DIS-611 | NET-612 | COS-613 | GL -614 | PRO-615 | WBS-616 | PLA-617 | STO-618 | WAR-619 | BIN-620 | BAT-621 | SER-622 | LEA-623 | REQ-624 | CON-625 | DEL-626 | GOO-627 | INV-628 | PAY-629 | INC-630 | SHI-631 | BIL-632 | BUY-633 | REQ-634 | APP-635 | APP-636 | STA-637 | BLO-638 | REA-639 | CON-640 | AGR-641 | PRI-642 | CON-643 | REB-644 | FRE-645 | DUT-646 | ORI-647 | HS -648 | LAS-649 |
| LINE-007 | DES-71 | VEN-72 | VEN-73 | UOM-74 | QTY-75 | UNI-76 | CUR-77 | LIN-78 | TAX-79 | TAX-710 | DIS-711 | NET-712 | COS-713 | GL -714 | PRO-715 | WBS-716 | PLA-717 | STO-718 | WAR-719 | BIN-720 | BAT-721 | SER-722 | LEA-723 | REQ-724 | CON-725 | DEL-726 | GOO-727 | INV-728 | PAY-729 | INC-730 | SHI-731 | BIL-732 | BUY-733 | REQ-734 | APP-735 | APP-736 | STA-737 | BLO-738 | REA-739 | CON-740 | AGR-741 | PRI-742 | CON-743 | REB-744 | FRE-745 | DUT-746 | ORI-747 | HS -748 | LAS-749 |
| LINE-008 | DES-81 | VEN-82 | VEN-83 | UOM-84 | QTY-85 | UNI-86 | CUR-87 | LIN-88 | TAX-89 | TAX-810 | DIS-811 | NET-812 | COS-813 | GL -814 | PRO-815 | WBS-816 | PLA-817 | STO-818 | WAR-819 | BIN-820 | BAT-821 | SER-822 | LEA-823 | REQ-824 | CON-825 | DEL-826 | GOO-827 | INV-828 | PAY-829 | INC-830 | SHI-831 | BIL-832 | BUY-833 | REQ-834 | APP-835 | APP-836 | STA-837 | BLO-838 | REA-839 | CON-840 | AGR-841 | PRI-842 | CON-843 | REB-844 | FRE-845 | DUT-846 | ORI-847 | HS -848 | LAS-849 |
| LINE-009 | DES-91 | VEN-92 | VEN-93 | UOM-94 | QTY-95 | UNI-96 | CUR-97 | LIN-98 | TAX-99 | TAX-910 | DIS-911 | NET-912 | COS-913 | GL -914 | PRO-915 | WBS-916 | PLA-917 | STO-918 | WAR-919 | BIN-920 | BAT-921 | SER-922 | LEA-923 | REQ-924 | CON-925 | DEL-926 | GOO-927 | INV-928 | PAY-929 | INC-930 | SHI-931 | BIL-932 | BUY-933 | REQ-934 | APP-935 | APP-936 | STA-937 | BLO-938 | REA-939 | CON-940 | AGR-941 | PRI-942 | CON-943 | REB-944 | FRE-945 | DUT-946 | ORI-947 | HS -948 | LAS-949 |
| LINE-010 | DES-101 | VEN-102 | VEN-103 | UOM-104 | QTY-105 | UNI-106 | CUR-107 | LIN-108 | TAX-109 | TAX-1010 | DIS-1011 | NET-1012 | COS-1013 | GL -1014 | PRO-1015 | WBS-1016 | PLA-1017 | STO-1018 | WAR-1019 | BIN-1020 | BAT-1021 | SER-1022 | LEA-1023 | REQ-1024 | CON-1025 | DEL-1026 | GOO-1027 | INV-1028 | PAY-1029 | INC-1030 | SHI-1031 | BIL-1032 | BUY-1033 | REQ-1034 | APP-1035 | APP-1036 | STA-1037 | BLO-1038 | REA-1039 | CON-1040 | AGR-1041 | PRI-1042 | CON-1043 | REB-1044 | FRE-1045 | DUT-1046 | ORI-1047 | HS -1048 | LAS-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.
| Item | Description | Vendor | Vendor part | UoM | Qty | Unit price | Currency | Line total | Tax code | Tax % | Discount % | Net | Cost center | GL account | Project | WBS | Plant | Storage loc | Warehouse | Bin | Batch | Serial | Lead time | Requested | Confirmed | Delivered | Goods receipt | Invoice | Payment terms | Incoterm | Ship-to | Bill-to | Buyer | Requester | Approver | Approved on | Status | Blocked | Reason | Contract | Agreement | Price list | Condition | Rebate | Freight | Duty | Origin | HS code | Last changed |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| LINE-001 | DES-11 | VEN-12 | VEN-13 | UOM-14 | QTY-15 | UNI-16 | CUR-17 | LIN-18 | TAX-19 | TAX-110 | DIS-111 | NET-112 | COS-113 | GL -114 | PRO-115 | WBS-116 | PLA-117 | STO-118 | WAR-119 | BIN-120 | BAT-121 | SER-122 | LEA-123 | REQ-124 | CON-125 | DEL-126 | GOO-127 | INV-128 | PAY-129 | INC-130 | SHI-131 | BIL-132 | BUY-133 | REQ-134 | APP-135 | APP-136 | STA-137 | BLO-138 | REA-139 | CON-140 | AGR-141 | PRI-142 | CON-143 | REB-144 | FRE-145 | DUT-146 | ORI-147 | HS -148 | LAS-149 |
| LINE-002 | DES-21 | VEN-22 | VEN-23 | UOM-24 | QTY-25 | UNI-26 | CUR-27 | LIN-28 | TAX-29 | TAX-210 | DIS-211 | NET-212 | COS-213 | GL -214 | PRO-215 | WBS-216 | PLA-217 | STO-218 | WAR-219 | BIN-220 | BAT-221 | SER-222 | LEA-223 | REQ-224 | CON-225 | DEL-226 | GOO-227 | INV-228 | PAY-229 | INC-230 | SHI-231 | BIL-232 | BUY-233 | REQ-234 | APP-235 | APP-236 | STA-237 | BLO-238 | REA-239 | CON-240 | AGR-241 | PRI-242 | CON-243 | REB-244 | FRE-245 | DUT-246 | ORI-247 | HS -248 | LAS-249 |
| LINE-003 | DES-31 | VEN-32 | VEN-33 | UOM-34 | QTY-35 | UNI-36 | CUR-37 | LIN-38 | TAX-39 | TAX-310 | DIS-311 | NET-312 | COS-313 | GL -314 | PRO-315 | WBS-316 | PLA-317 | STO-318 | WAR-319 | BIN-320 | BAT-321 | SER-322 | LEA-323 | REQ-324 | CON-325 | DEL-326 | GOO-327 | INV-328 | PAY-329 | INC-330 | SHI-331 | BIL-332 | BUY-333 | REQ-334 | APP-335 | APP-336 | STA-337 | BLO-338 | REA-339 | CON-340 | AGR-341 | PRI-342 | CON-343 | REB-344 | FRE-345 | DUT-346 | ORI-347 | HS -348 | LAS-349 |
| LINE-004 | DES-41 | VEN-42 | VEN-43 | UOM-44 | QTY-45 | UNI-46 | CUR-47 | LIN-48 | TAX-49 | TAX-410 | DIS-411 | NET-412 | COS-413 | GL -414 | PRO-415 | WBS-416 | PLA-417 | STO-418 | WAR-419 | BIN-420 | BAT-421 | SER-422 | LEA-423 | REQ-424 | CON-425 | DEL-426 | GOO-427 | INV-428 | PAY-429 | INC-430 | SHI-431 | BIL-432 | BUY-433 | REQ-434 | APP-435 | APP-436 | STA-437 | BLO-438 | REA-439 | CON-440 | AGR-441 | PRI-442 | CON-443 | REB-444 | FRE-445 | DUT-446 | ORI-447 | HS -448 | LAS-449 |
| LINE-005 | DES-51 | VEN-52 | VEN-53 | UOM-54 | QTY-55 | UNI-56 | CUR-57 | LIN-58 | TAX-59 | TAX-510 | DIS-511 | NET-512 | COS-513 | GL -514 | PRO-515 | WBS-516 | PLA-517 | STO-518 | WAR-519 | BIN-520 | BAT-521 | SER-522 | LEA-523 | REQ-524 | CON-525 | DEL-526 | GOO-527 | INV-528 | PAY-529 | INC-530 | SHI-531 | BIL-532 | BUY-533 | REQ-534 | APP-535 | APP-536 | STA-537 | BLO-538 | REA-539 | CON-540 | AGR-541 | PRI-542 | CON-543 | REB-544 | FRE-545 | DUT-546 | ORI-547 | HS -548 | LAS-549 |
| LINE-006 | DES-61 | VEN-62 | VEN-63 | UOM-64 | QTY-65 | UNI-66 | CUR-67 | LIN-68 | TAX-69 | TAX-610 | DIS-611 | NET-612 | COS-613 | GL -614 | PRO-615 | WBS-616 | PLA-617 | STO-618 | WAR-619 | BIN-620 | BAT-621 | SER-622 | LEA-623 | REQ-624 | CON-625 | DEL-626 | GOO-627 | INV-628 | PAY-629 | INC-630 | SHI-631 | BIL-632 | BUY-633 | REQ-634 | APP-635 | APP-636 | STA-637 | BLO-638 | REA-639 | CON-640 | AGR-641 | PRI-642 | CON-643 | REB-644 | FRE-645 | DUT-646 | ORI-647 | HS -648 | LAS-649 |
| LINE-007 | DES-71 | VEN-72 | VEN-73 | UOM-74 | QTY-75 | UNI-76 | CUR-77 | LIN-78 | TAX-79 | TAX-710 | DIS-711 | NET-712 | COS-713 | GL -714 | PRO-715 | WBS-716 | PLA-717 | STO-718 | WAR-719 | BIN-720 | BAT-721 | SER-722 | LEA-723 | REQ-724 | CON-725 | DEL-726 | GOO-727 | INV-728 | PAY-729 | INC-730 | SHI-731 | BIL-732 | BUY-733 | REQ-734 | APP-735 | APP-736 | STA-737 | BLO-738 | REA-739 | CON-740 | AGR-741 | PRI-742 | CON-743 | REB-744 | FRE-745 | DUT-746 | ORI-747 | HS -748 | LAS-749 |
| LINE-008 | DES-81 | VEN-82 | VEN-83 | UOM-84 | QTY-85 | UNI-86 | CUR-87 | LIN-88 | TAX-89 | TAX-810 | DIS-811 | NET-812 | COS-813 | GL -814 | PRO-815 | WBS-816 | PLA-817 | STO-818 | WAR-819 | BIN-820 | BAT-821 | SER-822 | LEA-823 | REQ-824 | CON-825 | DEL-826 | GOO-827 | INV-828 | PAY-829 | INC-830 | SHI-831 | BIL-832 | BUY-833 | REQ-834 | APP-835 | APP-836 | STA-837 | BLO-838 | REA-839 | CON-840 | AGR-841 | PRI-842 | CON-843 | REB-844 | FRE-845 | DUT-846 | ORI-847 | HS -848 | LAS-849 |
| LINE-009 | DES-91 | VEN-92 | VEN-93 | UOM-94 | QTY-95 | UNI-96 | CUR-97 | LIN-98 | TAX-99 | TAX-910 | DIS-911 | NET-912 | COS-913 | GL -914 | PRO-915 | WBS-916 | PLA-917 | STO-918 | WAR-919 | BIN-920 | BAT-921 | SER-922 | LEA-923 | REQ-924 | CON-925 | DEL-926 | GOO-927 | INV-928 | PAY-929 | INC-930 | SHI-931 | BIL-932 | BUY-933 | REQ-934 | APP-935 | APP-936 | STA-937 | BLO-938 | REA-939 | CON-940 | AGR-941 | PRI-942 | CON-943 | REB-944 | FRE-945 | DUT-946 | ORI-947 | HS -948 | LAS-949 |
| LINE-010 | DES-101 | VEN-102 | VEN-103 | UOM-104 | QTY-105 | UNI-106 | CUR-107 | LIN-108 | TAX-109 | TAX-1010 | DIS-1011 | NET-1012 | COS-1013 | GL -1014 | PRO-1015 | WBS-1016 | PLA-1017 | STO-1018 | WAR-1019 | BIN-1020 | BAT-1021 | SER-1022 | LEA-1023 | REQ-1024 | CON-1025 | DEL-1026 | GOO-1027 | INV-1028 | PAY-1029 | INC-1030 | SHI-1031 | BIL-1032 | BUY-1033 | REQ-1034 | APP-1035 | APP-1036 | STA-1037 | BLO-1038 | REA-1039 | CON-1040 | AGR-1041 | PRI-1042 | CON-1043 | REB-1044 | FRE-1045 | DUT-1046 | ORI-1047 | HS -1048 | LAS-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.
| Document | GR/IR | Balance |
|---|---|---|
| GR-88104 | Open | 1,240.00 |
| GR-88102 | Cleared | 0.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-88212 | Initech GmbH | $730.25 |
| PO-88214 | Stark Components | $56,000.00 |
| Subtotal CC-1180 | $56,730.25 | |
| CC-4021 — Warehouse | ||
| PO-88210 | Acme 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
| Task | Status |
|---|---|
| PO-88213 · Dock 4 | Next |
| SO-51204 · Pick | Queued |
| CC-3092 · Count | Queued |
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
| Item | Quantity | Unit price | |||
|---|---|---|---|---|---|
| Ordered | Received | Billed | Ordered | Billed | |
| Hex bolt M8 × 40 | 2,400 | 2,400 | 2,400 | 0.42 | 0.42 |
| Hex nut M8 | 2,400 | 2,400 | 2,400 | 0.18 | 0.18 |
| Steel plate 3mm | 60 | 60 | 60 | 412.00 | 424.00 |
| Pallet, heat-treated | 120 | 80 | 120 | 18.00 | 18.00 |
| Washer M8, zinc | 4,800 | 4,800 | 4,800 | 0.04 | 0.04 |
| Threadlocker, 50ml | 24 | 24 | 24 | 9.60 | 9.60 |
| Anchor bolt M12 | 600 | 600 | 600 | 1.85 | 1.85 |
| Gasket sheet, 2mm | 15 | 15 | 15 | 62.00 | 62.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
| Class | Role |
|---|---|
| .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-sorton the<th>; re-ordering rows and updating the attribute is YOUR code (server or client). - Row checkboxes need
name/valueto POST selections. Wrap the rows and the bulk buttons in one<form>rather than reaching forhx-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 actiontype="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--secondaryhide 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 thebo:row-saveevent. - 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 — callinitTableToolbar()again after appending, same re-call convention asinitDataTables(). 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. Adddata-tone-textalongside 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-colinstead (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 viaResizeObserver. - Separately opt-in:
data-context-menu="menu-id"+initContextMenu()opens a.bo-dropdown__menuat the cursor on right-click instead of at a button. It only positions the menu — the items inside call whatever this page already documents (anaria-sortcontrol, adata-col-togglecheckbox); this behavior owns no sort or filter logic of its own.
Contrast — 33 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| 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 |