Skip to content

The column-driven data table — sorting, selection, detail rows, server data and virtual scrolling, rendered as a CSS grid with ARIA roles.

import { BbTable } from 'bitboss-ui';

On this page

Use it for

Reach for BbTable when you have rows of records with columns: an orders listing, a members admin, an audit log. You declare the columns; the component owns extraction, formatting, sorting indicators, selection controls, skeletons, empty states, detail rows and keyboard navigation.

Use something else when

  • BbSelect, BbCheckboxGroup or BbRadioGroup — options are being picked inside a form; a selectable table is not a form field
  • BbTree — the data is genuinely recursive: a file tree, nested categories. Flat rows with one level of detail stay here
  • BbSelectPopover — it is a picker that opens from a cell
  • BbPagination — you are after the paging controls

Pass Through

Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.

Name
Role
Marta Villoresi
Head of Product
Lukas Brandt
Engineering Manager
Sofia Marchetti
Design Lead

It reports intent. Your application owns the rows. Sort, selection, page, expansion and highlight arrive as v-models; apply them in a computed or send them to your API.

Coming from v2

In v3 the table is a CSS grid of <div>s with ARIA roles, not a <table>. The migration details are collected in the styling section, after the first working application flow.

An async orders list

Start with the shape most application screens need: an API-backed order list with filtering and server ordering.

ShippedShipped
Orders, filtered by status
Customer
ORD-2026-0491
Yuki Tanaka
Shipped
€958.90
ORD-2026-0463
Mateusz Kowalski
Shipped
€318.00
ORD-2026-0458
Rania Haddad
Shipped
€1,046.00
ORD-2026-0447
Mei Lin Chen
Shipped
€458.00

The provider forwards status and sort to the backend. Listing both in dependencies gives the table the request lifecycle: debounce, loading state, refetch and out-of-order response protection. Do not add a parallel watcher.

Set item-value before adding row state. It is the stable key used by selection, expansion and highlight. Without it, those models compare object references and break after a refetch replaces the records.

caption names the table for assistive technology. It is visually hidden by default; add display-caption when the page needs a visible title.

For a local array, the same contract is smaller:

Workspace equipment catalogue
Product
SKU
Category
Stock
Aeris Ergonomic Task Chair
FUR-CHR-1042
Furniture
34
Lumen Sit-Stand Desk 160
FUR-DSK-1187
Furniture
12
Meridian Footrest
FUR-FTR-1093
Furniture
148
Quill Whiteboard 120×90
FUR-WBD-1155
Furniture
5
Northlight 27" 4K Display
DIS-MON-3301
Displays
61
Northlight 34" Ultrawide
DIS-MON-3318
Displays
8

Every row in items mounts. A few hundred rows on a client-only screen is fine; past roughly a thousand, either page it or add virtual.

The column definition

Everything a cell needs is declared in the column object, not in markup — which is why most tables need no cell slots at all.

Orders placed in June
Reference
Customer
Placed
Lines
Total
ORD-2026-0417
Diego Ramirez
2 Jun 2026
2
€1,298.00
ORD-2026-0418
Sofia Marchetti
5 Jun 2026
2
€338.80
ORD-2026-0421
Rafael Costa
11 Jun 2026
1
€899.00
ORD-2026-0426
Idris Bello
18 Jun 2026
1
€477.00
ORD-2026-0430
Ingrid Halvorsen
24 Jun 2026
1
€2,196.00
ORD-2026-0435
Nikhil Raghavan
1 Jul 2026
2
€257.00

key extracts the value, and dot paths reach nested fields (customer.address.city). formatter(content, key, item) transforms it, and it runs even when the extracted value is nullish — that is what makes a virtual column work: give it a key that matches no field and compute the cell from item. Pass formatOnNull: false when a formatter must only ever see real values. placeholder fills the cell when the pipeline still ends nullish, and align (left, center, right) sets the column's alignment; the table-level align prop sets the default for all of them.

Two things are not columns: actions and select. The table renders both itself: the selection column from the selectable prop, the actions column from the #actions slot. Providing the slot is what creates the column; there is no prop. Putting { key: 'actions' } in columns adds an ordinary data column reading item.actions, so you get a stray empty cell next to the real one. Dev builds warn.

Classes come from the same object. thClass styles the header cell, tdClass the data cells, rowClass the whole row; tdClass and rowClass may be functions of (content, key, item). The table-level thClass / tdClass / rowClass / headerRowClass props apply everywhere, and a row accumulates the table's classes plus every column's.

Stock levels, with depleted rows flagged
Product
SKU
Status
On hand
Lumen Sit-Stand Desk 160
FUR-DSK-1187
In stock
12
Quill Whiteboard 120×90
FUR-WBD-1155
Low stock
5
Northlight 34" Ultrawide
DIS-MON-3318
Low stock
8
Northlight 24" Portable Monitor
DIS-MON-3352
On backorder
0
Vellum Low-Profile Keyboard
INP-KBD-4119
Low stock
3
Orbit Trackball
INP-MSE-4215
Discontinued
0
Slate Drawing Tablet
INP-TAB-4288
In stock
19

rowClass on a column is new in v3, and it collides. In v2 an unknown field on a column object was inert, so applications parked their own data there — a per-column width class named rowClass is the common one. ColumnClasses already accepts a string, so a leftover rowClass: 'w-40' still type-checks and is now merged onto the whole row instead of doing nothing. Grep your column definitions before you upgrade; sortable has the same problem, with a type error on top.

Column widths

Grid tracks are the width API. Numbers and numeric strings are pixels; values with units pass through. Unspecified columns size to content and share the remaining space. Use fixed to split that space equally. Declare width when wrapping or proportions must stay predictable. The migration notes below cover the sizing differences from v2.

Sortable columns

Mark a column sortable and bind v-model:sort. The model is an ordered array of [columnKey, direction] entries, and the array order is the priority.

Catalogue, sortable by product, category and price
Kestrel Dual Monitor Arm
Accessories
€129.90
Northlight 27" 4K Display
Displays
€549.00
Northlight 34" Ultrawide
Displays
€899.00
Northlight 24" Portable Monitor
Displays
€329.00
Aeris Ergonomic Task Chair
Furniture
€489.00
Lumen Sit-Stand Desk 160
Furniture
€749.00
Meridian Footrest
Furniture
€59.90
Quill Whiteboard 120×90
Furniture
€179.00

Sort model: [["category","asc"]]

Clicking anywhere on a sortable header — its whitespace included — cycles that column unsorted → ascending → descending → removed. A direction flip updates the entry in place, so a column keeps its priority in a multi-column sort and no other entry is touched. The indicator is one arrow: nothing at rest, faint while the header is hovered or focused, solid once the column is sorted.

The table reports the model and stops there. Apply it yourself — with a computed over your array, as above, or by forwarding it to the server as orderBy and refetching through dependencies. Nothing sorts behind your back, which is also why the extra sort button described below is visible rather than dangerous.

For assistive technology, a sortable header renders a real <button> and its cell carries aria-sort (none, ascending or descending). A custom #header:<key> slot replaces that button, so keep sorting reachable by rendering your own button and calling the scope's toggleSort; to merely add something next to the control, use the affix slots instead. The column-level sorted field only seeds aria-sort when no live model entry exists — to open pre-sorted, seed v-model:sort, as the demo does.

sortable is new on the column type in v3, and it breaks in two directions at once. v2 had no such field, so an application that parked a backend sort key there — type Column = BbTableColumn<Order> & { sortable?: string } — now intersects boolean & string, which is never; every column literal fails with a message that never mentions BbTableColumn. And the table coerces the field (!!column.sortable), so a sort-key string is truthy and turns the built-in sort UI on: an application with its own sortable headers draws two.

diff
- type Column = BbTableColumn<Order> & { sortable?: string };
+ type Column = BbTableColumn<Order> & { sortKey?: string };

Omit<BbTableColumn<Order>, 'sortable'> silences the compiler and keeps the second header. Rename the field, or strip it at the boundary (columns.map(({ sortable, ...rest }) => rest)).

Selecting rows

Add selectable and bind v-model. Multiple is the default, and in multiple mode the model must already be an array — the table throws otherwise.

Select products to add to a purchase order
Catalogue
Product
SKU
On hand
Aeris Ergonomic Task Chair
FUR-CHR-1042
34
Lumen Sit-Stand Desk 160
FUR-DSK-1187
12
Meridian Footrest
FUR-FTR-1093
148
Quill Whiteboard 120×90
FUR-WBD-1155
5
Northlight 27" 4K Display
DIS-MON-3301
61
Northlight 34" Ultrawide
DIS-MON-3318
8

Selected ids: none

A selectable table renders as a <fieldset>, so it needs an accessible name. legend is that name; it falls back to caption, then to a localized generic string, so the grouping is never nameless — but a legend that says what the selection is for beats both fallbacks. Each row's control carries a visually hidden label built from the row's cell contents, prefixed with the localized "select" word; select-text changes that word and accessible-label replaces the whole label with your own function of (columns, item). The cells arrive in render order, so look them up by key, never by position.

With name set, every selected value renders a hidden input and the table submits inside a plain <form>. selectable also accepts a predicate (item) => boolean to gate individual rows, and max caps the count — at the cap the unselected checkboxes disable, so a user can always trade one choice for another. item:selected and item:unselected fire per toggle with the row's value; keep v-model as the state and use the events for side effects.

Select-all is designed for "everything across every page". The header checkbox is bound with v-model:select-all; while it is on, every row reads as selected and unchecking one adds its value to v-model:unselected-items, so your bulk endpoint receives { all: true, except: [...] } rather than thousands of ids. Two consequences follow, and both bite quietly. Turning select-all on does not re-emit update:modelValue when the model is already empty, so never infer "everything is selected" from modelValue — read select-all and unselected-items. And the hidden inputs serialize modelValue only, so all-mode posts nothing at all through a native form; submit it through a handler instead.

In v3 the prop that hides the header checkbox inverted: v2's allow-select-all (default true) is now disable-select-all (default false). The old name lands in $attrs and does nothing, so this is silent.

diff
- <BbTable selectable :allow-select-all="false" … />
+ <BbTable selectable disable-select-all … />

:multiple="false" renders radios and the model becomes a single value or null:

Choose the replacement product
Replacement candidates
Choose
Product
Category
Price
Northlight 27" 4K Display
Displays
€549.00
Northlight 34" Ultrawide
Displays
€899.00
Northlight 24" Portable Monitor
Displays
€329.00

Model: null

Selection tracked by item-value survives a refetch, but stale ids do not prune themselves. enforce-coherence drops values whose row is no longer in the result set after every load — see Server data and pages for the one case where you must leave it off.

Loading and empty

loading means two different things and the table tells them apart.

Catalogue
Product
SKU
On hand
Aeris Ergonomic Task Chair
FUR-CHR-1042
34
Lumen Sit-Stand Desk 160
FUR-DSK-1187
12
Meridian Footrest
FUR-FTR-1093
148
Quill Whiteboard 120×90
FUR-WBD-1155
5
Northlight 27" 4K Display
DIS-MON-3301
61

With nothing to show yet — a first load, or a filter that emptied the table — it draws skeleton rows, between four and ten of them, shaped per column by the skeleton field (text, avatar, avatar-text, image, badge). With rows already on screen — a refetch, a poll, a save-then-reload — the rows stay put, dimmed, under an indeterminate bar along the header. Blanking a populated table on every refetch loses the reader's place, so it does not happen any more. That is a v3 behaviour change: v2 replaced the rows with skeletons every time.

While loading, the header and the rows are inert. A refetch is about to replace the rows, so editing, selecting or sorting them races the incoming data. interactive-while-loading is the escape hatch for a table that must stay usable during a background refresh. The loading announcement is never inert, so the busy state is always conveyed: aria-busy goes on the table and loading-text is announced through a polite live region. It is sr-only — never painted, the skeleton carries the visual signal — and it has a localized default, so omitting it still announces. Set it when a specific message beats the generic one.

The #loading slot replaces the skeleton, so it follows the skeleton's rule: in v3 it renders on the first load only, never on a refetch. If you used it in v2 to style refetches, that role is gone — the built-in dim and progress bar covers them now.

When the result is empty, the table shows its no-data row. no-data-text replaces the localized default text; #no-data replaces the whole content.

Catalogue, filtered
Product
Category
On hand

No product matches "bookshelf".

That slot fills the table's own full-width cell — already centred, already spanning every column with an aria-colspan that counts the selection and actions columns. Pass content, not a row. In v2 the slot replaced the empty-state <tr>, so anything that was not a row rendered loose and left-aligned; that same content now centres correctly, and a hand-rolled <tr> / <td> must be deleted rather than kept.

There is no error-text prop and no table-level error state. A failed server visit is a full error page; a rejected items provider is logged, the previous rows are kept, and the failure is yours to surface — through #no-data if the table should say why it is empty.

Server data and pages

Set items to a function and the table calls it, tracks the loading state and discards out-of-order responses.

ShippedShipped
Orders, filtered by status
Customer
ORD-2026-0491
Yuki Tanaka
Shipped
€958.90
ORD-2026-0463
Mateusz Kowalski
Shipped
€318.00
ORD-2026-0458
Rania Haddad
Shipped
€1,046.00
ORD-2026-0447
Mei Lin Chen
Shipped
€458.00

The provider receives (prefill, selected): prefill is true on mount and false on a dependency-driven refetch, and selected is the current selection, which is where you reconcile it against the incoming rows. List your filters in dependencies and never write a watch(filter, refetch) — dependencies are compared by value, not by reference, so [filters] refetches when the contents change and not merely when the array identity does. deps-debounce-time throttles the burst. A plain array bypasses all of this.

enforce-coherence prunes the row-keyed models — v-model, v-model:unselected-items, v-model:highlighted, v-model:expanded-items — of values whose row is gone after each load. Single-value models reset to null. Never combine it with pagination fed through dependencies: every page turn is a refetch, so a cross-page selection, and the whole select-all plus unselected-items pattern, would be pruned away. On a paginated table, reconcile in the provider instead, where both the new rows and the current selection are in hand.

The table never slices. Hand it the current page as items and give it the paging facts:

Catalogue, eight products per page
Product
Category
On hand
Aeris Ergonomic Task Chair
Furniture
34
Lumen Sit-Stand Desk 160
Furniture
12
Meridian Footrest
Furniture
148
Quill Whiteboard 120×90
Furniture
5
Northlight 27" 4K Display
Displays
61
Northlight 34" Ultrawide
Displays
8
Northlight 24" Portable Monitor
Displays
0
Kestrel Dual Monitor Arm
Accessories
76

page, per-page and total-items are what let the table announce aria-rowcount and each row's absolute aria-rowindex, so a screen-reader user hears "row 12 of 26" rather than "row 4 of 8". With an array items, per-page and total-items default to its length, so a single-page table is accessible with page alone. Note that aria-rowcount now counts the header row — totalItems + 1 — so a test asserting the old number needs one added.

On a server-driven screen the provider forwards the filters, the sort model and the page, and returns the slice. Do not list the page in dependencies: a provider table already refetches when the shared page moves, so list only your own filters and sort.

Custom cells and headers

Override a column's body with a #<key> slot when the value needs markup rather than a string.

Catalogue with status badges
Product
SKU
Status
Aeris Ergonomic Task Chair
FUR-CHR-1042
In stock
Lumen Sit-Stand Desk 160
FUR-DSK-1187
In stock
Meridian Footrest
FUR-FTR-1093
In stock
Quill Whiteboard 120×90
FUR-WBD-1155
Low stock
Northlight 27" 4K Display
DIS-MON-3301
In stock
Northlight 34" Ultrawide
DIS-MON-3318
Low stock
Northlight 24" Portable Monitor
DIS-MON-3352
On backorder

Keep the column in columns even when you fully slot it — its label, alignment and width still come from there, and so does the formatter, whose output reaches the slot as content. The scope also carries item (your raw record), value (resolved through item-value), classes, and the row's whole interaction surface: selected / toggleSelected, highlighted / toggleHighlighted, expanded / toggleExpanded / expandProps, and sortOrder. The flags are one-way state; the toggle* callbacks are the write path.

The slot name is the key, lowercased, with every run of non-word characters turned into _, dashes included. So address.city is #address_city, issued-at is #issued_at, and issuedAt is #issuedat, which is why snake_case keys read better here. Building slot names in code? Import the normalizer, slotKey, rather than restating the rule: a hand copy that differs by one character makes the slot silently never match.

#header:<key> replaces a header, but that takes the built-in sort button with it. To add beside the header instead, use #header:<key>:prepend and #header:<key>:append:

Catalogue with an annotated header
SKU
(5 rows)
Aeris Ergonomic Task Chair
FUR-CHR-1042
34
Lumen Sit-Stand Desk 160
FUR-DSK-1187
12
Meridian Footrest
FUR-FTR-1093
148
Quill Whiteboard 120×90
FUR-WBD-1155
5
Northlight 27" 4K Display
DIS-MON-3301
61

The table-wide #header:prepend / #header:append apply one template to every data column's header, never to the structural select and actions cells, and receive columnKey so they can branch. A per-column affix wins over the table-wide one for that column; they never stack. Header scope is label, items, sortable, sortOrder, toggleSort and classes.

The structural regions have their own slots: #header:select, #header:actions, and #select to replace a row's checkbox or radio (scope: item, value, checked, disabled, readonly, inputName, toggleSelected). Replacing the selection control is the supported way to change it — rebuilding selection with your own checkbox in a cell slot is not.

Detail rows

Track the open rows in v-model:expanded-items and render their detail in #expand, a full-width row below the record it belongs to.

Orders, with their lines on demand
Reference
Customer
Total
Details
ORD-2026-0417
Diego Ramirez
€1,298.00
ORD-2026-0418
Sofia Marchetti
€338.80
ORD-2026-0421
Rafael Costa
€899.00
ORD-2026-0426
Idris Bello
€477.00
ORD-2026-0430
Ingrid Halvorsen
€2,196.00

The trigger here is the actions column, created by the #actions slot and labelled with actions-text (visually hidden, and localized by default). Its scope hands you toggleExpanded — a callback — plus the read-only expanded flag. Drive open and close through the callback; mutating the flag does nothing.

For a control that is not a plain button, v-bind the scope's expandProps onto it: you get aria-expanded and aria-controls kept in sync, a click handler that stops propagation so the row underneath does not also toggle its highlight, and Enter/Space handling for non-button elements. expandProps, expanded and toggleExpanded reach every cell slot, not just #actions, so a drill-down can put its chevron in the identity column where the eye already is; keep the actions column for actions.

Set item-value so expansion is keyed by id and survives a refetch. A BbTable inside #expand is independent by default; inherit-column-widths snaps its columns onto the parent's tracks so a breakdown reads as a continuation of the row above it, with a column's snap remapping individual tracks. An inheriting child never scrolls on its own — the parent's scrollport is the only one, which is what keeps the two grids locked together.

Opening a record

v-model:highlighted marks the row whose details are open. It is parallel to selection, not part of it, and it is single by design: one row is "the open one".

Orders — click or arrow to a row to preview it
Reference
Status
Total
ORD-2026-0417
Delivered
€1,298.00
ORD-2026-0418
Delivered
€338.80
ORD-2026-0421
Refunded
€899.00
ORD-2026-0426
Delivered
€477.00
ORD-2026-0430
Delivered
€2,196.00
ORD-2026-0435
Cancelled
€257.00

Highlight is opt-in, and the model's value is the switch. undefined means "never initialised" and leaves the mechanic off; null means "initialised and currently empty" and turns it on. So write ref(null), never ref() — a bound-but-undefined model warns in dev and stays inert. Unbound, a row click emits click:row and does nothing else: no class, no aria-current, no state. That is deliberate. A table has nowhere to put a highlight it invented, and in v2 it drove one anyway, into a state the application could neither read nor clear.

A highlighted row carries aria-current="true" and the bb-table-data__row--highlighted class, which the shipped stylesheet paints with a neutral fill and a brand accent bar. row-class replaces that look, as the demo does. Clicking the highlighted row again clears it back to null; clicks inside interactive elements and clicks that end a text selection are ignored, so dragging to copy a cell never mutates state.

keyboard-navigation makes the body a single tab stop with a roving tabindex: Arrow keys move the focused row, PageUp/PageDown stride ten, Home/End jump, and Enter or Space activate through the same path as a click. Focus and highlight stay independent — arrowing over rows opens nothing until the reader commits. Screen readers in browse mode intercept the arrows, so the reader takes the usual focus-mode step first.

Row events fire with (event, item): the native event and your record exactly as you passed it in items. In v2 the second argument was the table's internal row wrapper, with your record buried at row.item, and a third selected boolean followed it. Both are gone. row.item is now undefined, the third parameter is undefined, and a handler typed with three parameters is a type error.

diff
- <BbTable @click:row="(event, row, selected) => open(row.item, selected)" />
+ <BbTable @click:row="(event, item) => open(item, selectedIds.includes(item.id))" />

Reserve click:row, dblclick:row and contextmenu:row for side effects that are not navigation. Row navigation goes through a real link, never a row handler: render a BbButton with href (or to in a router app) in a cell or the actions column, and the reader gets middle-click and open-in-new-tab for free. Overlays opened from inside a row — a popover, a dropdown, a picker, a dialog — swallow their own clicks, so no @click.stop wrapper is needed around them.

Wide, long and dense tables

compact tightens the row padding. fixed-headers sticks the header row group to the top of the scrollport, and fixed: 'left' | 'right' on a column definition pins that column while the rest scrolls horizontally.

Catalogue — the header sticks, the first column is pinned
Product
SKU
Category
Price
On hand
Status
Updated
Aeris Ergonomic Task Chair
FUR-CHR-1042
Furniture
€489.00
34
In stock
2026-08-18
Lumen Sit-Stand Desk 160
FUR-DSK-1187
Furniture
€749.00
12
In stock
2026-08-21
Meridian Footrest
FUR-FTR-1093
Furniture
€59.90
148
In stock
2026-07-30
Quill Whiteboard 120×90
FUR-WBD-1155
Furniture
€179.00
5
Low stock
2026-08-11
Northlight 27" 4K Display
DIS-MON-3301
Displays
€549.00
61
In stock
2026-08-25
Northlight 34" Ultrawide
DIS-MON-3318
Displays
€899.00
8
Low stock
2026-08-24
Northlight 24" Portable Monitor
DIS-MON-3352
Displays
€329.00
0
On backorder
2026-08-06
Kestrel Dual Monitor Arm
ACC-ARM-2210
Accessories
€129.90
76
In stock
2026-08-14
Anchor USB-C Dock, 11-in-1
ACC-DCK-2118
Accessories
€199.00
43
In stock
2026-08-19
Anchor 100W GaN Charger
ACC-CHG-2153
Accessories
€69.00
210
In stock
2026-08-27
Meridian Laptop Stand
ACC-STD-2172
Accessories
€84.50
97
In stock
2026-08-02
Vellum Mechanical Keyboard
INP-KBD-4102
Input Devices
€159.00
54
In stock
2026-08-23
Vellum Low-Profile Keyboard
INP-KBD-4119
Input Devices
€139.00
3
Low stock
2026-08-15
Orbit Wireless Mouse
INP-MSE-4207
Input Devices
€69.90
182
In stock
2026-08-28
Orbit Trackball
INP-MSE-4215
Input Devices
€94.90
0
Discontinued
2026-05-12
Slate Drawing Tablet
INP-TAB-4288
Input Devices
€249.00
19
In stock
2026-08-09
Cadence Studio Headphones
AVI-HPH-5108
Audio & Video
€279.00
27
In stock
2026-08-20
Cadence Conference Speaker
AVI-SPK-5142
Audio & Video
€349.00
6
Low stock
2026-08-12
Cirrus 4K Webcam
AVI-CAM-5170
Audio & Video
€189.00
88
In stock
2026-08-26
Cirrus Broadcast Microphone
AVI-MIC-5194
Audio & Video
€219.00
0
On backorder
2026-07-24
Portage 2TB NVMe Drive
STO-SSD-7203
Storage
€229.00
115
In stock
2026-08-22
Portage 8TB Desktop Drive
STO-HDD-7241
Storage
€189.00
41
In stock
2026-08-08
Beacon Mesh Router
NET-RTR-8110
Networking
€279.00
22
In stock
2026-08-17
Beacon PoE Switch, 8-Port
NET-SWT-8127
Networking
€159.00
2
Low stock
2026-08-05
Halo Desk Lamp
LGT-LMP-6104
Lighting
€119.00
64
In stock
2026-08-13
Halo Ring Light 12"
LGT-RNG-6120
Lighting
€89.00
0
Discontinued
2026-04-29

The table is its own scroll box. .bb-table ships overflow-x: auto and min-width: 0, so a table wider than its container scrolls without a wrapper — and once you cap its height, it is also the vertical scrollport the stuck header and the pinned cells resolve against. Put the max-height on the table, as the demo does, rather than on a <div> around it.

What that still cannot do is make room for itself. Inside a flex row an item's automatic minimum size is its own content, so an unconstrained ancestor between the page and the table widens the row instead of letting the table scroll. Give that ancestor min-width: 0 — and only on the flex row axis; a cross-axis child of a column-direction flex parent already has a definite width.

The pin belongs to the column, so it follows the column through a reorder and a hidden pinned column pins nothing. Pin a run from the edge and the offsets add up, the second left-pinned column sticking right after the first. The selection and actions columns have no definition to carry the field, so they pin through the table with fixed-select and fixed-actions. Pinned cells and the stuck header band are opaque and paint the table's --bg local, so on a surface that is not the panel colour, set that one knob.

When content cannot fit, the table keeps every column at its measured minimum — its header label included — and overflows rather than clipping. That is by design. Truncating cells yourself with overflow: hidden removes that column's content floor, so declare a width on any column you truncate.

On a phone, collapse rather than scroll: a ten-column ops table as a horizontal scroller loses the identity column first, which is the one thing the reader is looking for. Drop the secondary keys from columns at the breakpoint and stack what survives into the identity cell. Do not hide cells with display: none — a hidden grid item leaves its track in place and every following cell shifts one column over.

Once the list is long, virtual windows the body:

Every stock movement of the year
Movement
Product
SKU
Qty
MOV-00001
Aeris Ergonomic Task Chair
FUR-CHR-1042
1
MOV-00002
Lumen Sit-Stand Desk 160
FUR-DSK-1187
2
MOV-00003
Meridian Footrest
FUR-FTR-1093
3
MOV-00004
Quill Whiteboard 120×90
FUR-WBD-1155
4
MOV-00005
Northlight 27" 4K Display
DIS-MON-3301
5
MOV-00006
Northlight 34" Ultrawide
DIS-MON-3318
6
MOV-00007
Northlight 24" Portable Monitor
DIS-MON-3352
7
MOV-00008
Kestrel Dual Monitor Arm
ACC-ARM-2210
8
MOV-00009
Anchor USB-C Dock, 11-in-1
ACC-DCK-2118
9
MOV-00010
Anchor 100W GaN Charger
ACC-CHG-2153
10
MOV-00011
Meridian Laptop Stand
ACC-STD-2172
11
MOV-00012
Vellum Mechanical Keyboard
INP-KBD-4102
12
MOV-00013
Vellum Low-Profile Keyboard
INP-KBD-4119
1
MOV-00014
Orbit Wireless Mouse
INP-MSE-4207
2
MOV-00015
Orbit Trackball
INP-MSE-4215
3
MOV-00016
Slate Drawing Tablet
INP-TAB-4288
4
MOV-00017
Cadence Studio Headphones
AVI-HPH-5108
5
MOV-00018
Cadence Conference Speaker
AVI-SPK-5142
6
MOV-00019
Cirrus 4K Webcam
AVI-CAM-5170
7
MOV-00020
Cirrus Broadcast Microphone
AVI-MIC-5194
8
MOV-00021
Portage 2TB NVMe Drive
STO-SSD-7203
9
MOV-00022
Portage 8TB Desktop Drive
STO-HDD-7241
10
MOV-00023
Beacon Mesh Router
NET-RTR-8110
11
MOV-00024
Beacon PoE Switch, 8-Port
NET-SWT-8127
12
MOV-00025
Halo Desk Lamp
LGT-LMP-6104
1
MOV-00026
Halo Ring Light 12"
LGT-RNG-6120
2
MOV-00027
Aeris Ergonomic Task Chair
FUR-CHR-1042
3
MOV-00028
Lumen Sit-Stand Desk 160
FUR-DSK-1187
4

5000 rows; a few dozen are in the DOM at any moment.

Only the rows in the scrollport plus a few on each side are mounted; two spacer rows hold the scrollport at the full height. Give it a scroller by bounding the height, as above; left unbounded, the page becomes the scroller. virtual implies a stuck header, because a windowed body without column names is a scroller rather than a table. Rows keep their absolute aria-rowindex, focus is never lost to an unmount, and row heights stay dynamic.

One constraint is worth designing around: columns are sized once. A windowed body only ever holds a few rows, so the first rendered batch is measured and those widths become floors. The cells of those columns then clip with an ellipsis instead of wrapping, so a longer value arriving later never spills into its neighbour. A column that must wrap — prose, notes — needs a declared width, which wins as always. Nested tables inside expand rows are never windowed, and virtual is ignored on a table that inherits its widths or that replaces #tbody. Browser find only reaches mounted rows, which was a deliberate call.

If the reader needs to move or size the columns themselves, reorderable makes each header a drag handle writing v-model:order, and resizable puts a grip on each header's trailing edge reporting through @resize:column. Both are documented on the API page.

Driving the table from outside

Give the table an id and useBbTableContext(id) hands you a live, two-way handle on its state from anywhere on the page — no prop drilling, no template refs.

0 selected
Select products for a bulk price update
Catalogue
Product
Price
Aeris Ergonomic Task Chair
€489.00
Lumen Sit-Stand Desk 160
€749.00
Meridian Footrest
€59.90
Quill Whiteboard 120×90
€179.00
Northlight 27" 4K Display
€549.00
Northlight 34" Ultrawide
€899.00

The handle mirrors selected, unselected, all, highlighted, sort, page, perPage, totalItems and totalPages, each a writable computed: read .value, assign .value to drive the table. It works alongside the v-models rather than instead of them, so a table can be bound normally and still be readable from a toolbar. Every field returns a stable empty baseline — [], false, null, 0 — even before the table mounts, so nothing needs a guard.

BbPagination joins the same context through its table-id prop, which is the zero-wiring alternative to sharing a page ref: the paging props are symmetric across the pair, so the server's numbers can go to whichever component is convenient.

Two things to know before you debug it. The id is a rendezvous key and a typo does not error — it silently creates a second state node, so your toolbar looks alive while nothing reaches the table. If a bulk bar "works" but the table never reacts, check the spelling first; isReady only tells you whether paging data is real, not whether a table is attached. And the context is client-side only: during SSR the handle operates on detached state, and cross-component wiring starts at hydration.

If you wrote against the v3 alphas, the row count on the handle was renamed: ctx.total is now ctx.totalItems, everywhere including the initial seed. Reading the old name yields undefined and assigning to it throws.

The grid substrate and its styling hooks

Style the table through its classes and its ARIA roles. Element names match nothing.

Coming from v2

BbTable now renders role-complete <div> elements as a CSS grid. Props, slots, events and models did not change, but markup-dependent behavior did:

  • Element selectors (table, thead, tbody, tr, th, td) and global table CSS no longer match.
  • #thead, #tbody and #tfoot require <div> rows with the correct ARIA roles. A <tr> inside the grid cannot land on its tracks.
  • Browser copy produces one value per line. Build “copy as TSV” from items.
  • Printed grids do not repeat their header. Use a print-specific report view.
  • caption is now a <div> before the grid root, with the same accessible name.
  • Flexible surplus is shared equally, not by content. The measured 748px/52px pair becomes 544px/256px. In narrow containers, the longest flexible column can be about 22% narrower; mixing fixed and flexible tracks can move a column by about 19px.
  • A cell that scrolls no longer floors its track. Ellipsis works, but the old max-width: 0 trick collapses the cell to its padding.
  • Percent widths in overflow resolve against the grid's min-content width. The measured 30% column changes from 181px to 303px. Non-overflowing tables do not change.
  • With fixed, undeclared widths split the available space equally. If every column has a width, remaining space follows those proportions; selection and actions stay at content width.

The layout requires subgrid (Chrome/Edge 117, Firefox 71, Safari 16). Vertical centering through block align-content requires Chrome 123, Safari 17.4 or Firefox 125; older versions top-align cell content. There is no @supports fallback.

The surface is --bg, a local on .bb-table defaulting to var(--bb-panel). Cells are transparent at rest so the row can tint them, but anything that slides over scrolled content — the stuck header band, pinned cells — is opaque and paints --bg. Set it once when the table sits on another surface and everything pinned follows:

css
.my-card .bb-table {
    --bg: var(--card-bg);
}

The other locals on .bb-table are --padding-x (12px, 8px when compact), --padding-y (6px, 4px), --cell-h (36px, 32px) and --actions-spacing (8px). Note that these tightened in v3 from v2's 16px / 8px / 42px. Everything the table publishes — the per-track bridge a nested table reads, the edge offsets — lost its bb- prefix in v3 (--bb-table-offset-start--offset-start, and so on), and --fill and --natural-width were removed outright. All of them fail silently, so grep your CSS and any style-scanning JavaScript for --bb-table-; no name with that prefix exists any more.

RegionClassRole
container.bb-table
grid root.bb-table__tabletable
caption.bb-table-caption— (before the root)
header group.bb-table__headrowgroup
header row.bb-table-header-rowrow
header cell.bb-table-header (+ --select, --actions)columnheader
body group.bb-table__bodyrowgroup
data row.bb-table-data__row (+ --highlighted)row
data cell.bb-table-data__cell (+ --select)cell
actions cell.bb-table__cell--actionscell
expand row.bb-table-expand__row > .bb-table-expand__cellrow > cell
empty state.bb-table-no-data__row > .bb-table-no-data__cellrow > cell
skeleton row.bb-table-skeleton__row > .bb-table-skeleton__cellrow > cell
footer group.bb-table__footrowgroup only with #tfoot

[role='row'], [role='cell'] and [role='columnheader'] are supported hooks, not incidental markup — use them when you mean "every row kind", and the classes when you mean one kind. Cells are direct children of their row, so :first-child and :nth-child(n) address columns.

Translating a v2 stylesheet: table.bb-table__table, thead.bb-table__head, tbody.bb-table__body, tfoot.bb-table__foot, caption.bb-table-caption, tr[role='row'] (or .bb-table-data__row when you meant data rows only — a bare tr matched every row kind), th.bb-table-header, td.bb-table-data__cell. :first-child and :last-child carry over verbatim, td:nth-of-type(n) becomes :nth-child(n) since the children are type-uniform now, and colgroup / col are gone — declare width on the column instead.

Watch the specificity while you translate. [role='cell'] is an attribute selector, td was a type selector, so the translated rule is stronger than the one it replaces and an override that used to lose by one point can now win. The library holds its own body-cell rules at two classes deliberately, so an override that beat .bb-table tbody tr td at that weight still wins; the one band that loses ground is a rule of exactly one class plus type selectors. Give it one more class.

Some geometry is not yours to change: display: grid and gap: 0 on the root, the row groups and the rows; grid-column: 1 / -1 and contain: inline-size on the full-width cells, without which expand-row content resizes the parent's columns; white-space: nowrap on the actions cell, which is what makes its max-content track the width of the controls side by side; and position: sticky on .bb-table__head under fixed-headers — sticky lives on the row group, because a sticky cell inside a subgrid cannot stick in any engine.

Replacing a whole row group

#thead, #tbody and #tfoot hand you a region, and the markup you pass is grid markup:

Order lines with a total row
Product
Qty
Unit price
Lumen Sit-Stand Desk 160
1
€749.00
Northlight 27" 4K Display
1
€549.00
Orbit Wireless Mouse
2
€69.90
Anchor USB-C Dock, 11-in-1
1
€199.00
Northlight 34" Ultrawide
1
€899.00
Total
€2,535.80

One <div role="row"> per row, holding one <div role="cell"> — or role="columnheader" in the head — per column. Rows are subgrid rows, so a cell takes its column track without a width of its own, and a spanning cell uses grid-column plus aria-colspan rather than colspan. #tfoot receives columnCount, the total rendered columns with the selection and actions columns included. A full-width row is grid-column: 1 / -1.

Nothing lints this. A slot ported from <tr>/<td> to plain <div>s without roles renders perfectly and ships a table that a screen reader reads as a pile of generic boxes. The role chain table → rowgroup → row → columnheader | cell is the whole of the table semantics here; if you replace a region, you own it.

Two side effects of replacing the header specifically: the table measures its column tracks off the header cells, so with a custom #thead it falls back to the first row that has one child per track — which means a custom header over an empty table publishes no tracks, and a nested table cannot inherit from it until rows arrive. And under fixed-headers a multi-row #thead now sticks as a block, where the old cell-level sticky stacked its rows on top of each other.