Skip to content

A column-driven data table with sorting, selection, detail rows, server data and virtual scrolling.

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

Also see: Fetching data, Coherence.

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 in Styling the grid.

Passthrough

Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.

Name
Role
Marta Villoresi
Head of Product

Marta Villoresi manages billing for two workspaces.

Lukas Brandt
Engineering Manager
Sofia Marchetti
Design Lead

An async orders list

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

PendingProcessingShipped
Customer
ORD-2026-0511
Sanne JonkerSanne Jonker
Pending
29 Aug
€199.00
ORD-2026-0507
Andrei PopescuAndrei Popescu
Pending
27 Aug
€627.90
ORD-2026-0502
Chiara BonettiChiara Bonetti
Pending
24 Aug
€179.00
ORD-2026-0496
Elif YilmazElif Yilmaz
Pending
21 Aug
€418.00
ORD-2026-0491
Yuki TanakaYuki Tanaka
Shipped
18 Aug
€958.90
ORD-2026-0480
Anneke de VriesAnneke de Vries
Processing
11 Aug
€1,286.80
ORD-2026-0474
Amara OkonkwoAmara Okonkwo
Processing
7 Aug
€777.90
ORD-2026-0463
Mateusz KowalskiMateusz Kowalski
Shipped
29 Jul
€318.00
ORD-2026-0458
Rania HaddadRania Haddad
Shipped
23 Jul
€1,046.00
ORD-2026-0447
Mei Lin ChenMei Lin Chen
Shipped
14 Jul
€458.00

Set items to a function and list what the request depends on in dependencies. The table then owns the request lifecycle: debounce, loading state, refetch and discarding out-of-order responses. Do not add a parallel watcher. Dependencies compare by value, so [filters] refetches when the filter contents change.

The provider receives one context object: reason ('initial', 'dependencies' or 'pagination'), modelValue (the current selection) and signal. Pass signal to your request: it aborts when a newer call starts.

Set item-value before adding row state. It is the stable key for selection, expansion and highlight; without it those models compare object references and break after a refetch. caption names the table for assistive technology (visually hidden unless you add display-caption).

For a local array, the same contract is smaller:

Product
SKU
Stock
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

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

Columns and cells

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

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
  • key extracts the value; dot paths reach nested fields (customer.address.city).
  • formatter(content, key, item) transforms it, and runs even when the value is nullish. That is what makes a virtual column work: a key that matches no field, computed from item (the Lines column above). formatOnNull: false opts out; placeholder fills a cell that still ends up empty.
  • rowHeader: true marks the column that names each row. Its cells become row headers, so a screen reader reads "ORD-2026-0418, 5 Jun 2026" instead of a bare date. They look the same.
  • align, width, thClass, tdClass and rowClass set presentation.

Widths are grid tracks. A number or a length (120, '8rem') freezes the column. A grid value ('2fr', 'minmax(0, 1fr)') is used as written, and 'max-content' keeps an ID on one line. Leave most columns unsized. The table estimates a floor from each column's content and keeps the values a line break would harm on one line: names and statuses, amounts with their currency, ranges, dates and codes. When room runs short, longer values wrap first, then prose narrows, then a long heading of several words takes two lines; it scrolls sideways only as a last resort. You rarely need whitespace-nowrap in a td-class. fixed splits the room equally instead.

select and actions are not columns. The selectable prop creates the selection column, and providing the #actions slot creates the actions column. Do not key a column like one of the table's own slots (actions, select, expand, empty and a few more), or its #<key> slot becomes the table's own. Dev builds warn for actions and select, and bitboss-ui check flags any colliding key when columns is written in the same file.

When a value needs markup rather than a string, override that column's body with a #<key> slot. Row-level state goes in row-class, which paints the whole row rather than one cell:

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

The slot name is the key in kebab-case. status is #status, customerId is #customer-id, address.city is #address-city and address2 is #address-2. For a key with accents or punctuation, write the name through the function instead of by hand: #[slotKey('Qualità')], with import { slotKey } from 'bitboss-ui'. A name that differs by one character never matches. Leftover camelCase or underscore names are reported twice: a development build warns, and bitboss-ui check names the new spelling.

Keep the slotted column in columns: its label, alignment and width still come from there, and the formatter's output reaches the slot as content. The scope also carries item (your record), value (the row's value through item-value, not the cell's) and the row's selected, expanded and highlighted flags with their toggle* callbacks.

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

SKU
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

A totals row takes one slot per column: #<key>:last fills that column's cell in a row after the data rows (#<key>:first puts one before). The cell sits on its column's own track, so it follows order, hidden columns and resizing. Style the row with pt:last-row.

Product
Qty
Unit price
Amount
Aeris Ergonomic Task Chair
2
€489.00
€978.00
Meridian Footrest
1
€59.90
€59.90
Kestrel Dual Monitor Arm
1
€129.90
€129.90
Halo Desk Lamp
1
€119.00
€119.00
Total
€1,286.80

items in the slot scope is the rows in view, so a total across pages comes from your server. Add fixed-last to keep the row in view while the body scrolls.

Coming from v2rowClass and sortable on a column

Both fields are new on the v3 column type. v2 ignored unknown column fields, so applications often parked their own data there. A leftover per-column rowClass: 'w-40' still type-checks and now lands on every row. A backend sort key parked in sortable intersects boolean & string (a type error that never mentions BbTableColumn), and at runtime the truthy string turns the built-in sort UI on beside your own. Rename both fields.

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

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.

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"]]

A click on a sortable header sorts by that column alone, cycling it through ascending, descending and unsorted. Shift+click (or Shift+Enter) adds the column instead: it joins the end of the model, and a direction flip updates its entry in place, so the column keeps its priority. Set multi-sort when a plain click should add too. The table never sorts your rows. Apply the model in a computed, as above, or forward it to the server and refetch through dependencies, as the first demo does. To open pre-sorted, seed the model; the column's sorted field only sets aria-sort for rows the server already returns in order.

A sortable header is a real <button> and its cell carries aria-sort. A custom #<key>:header slot replaces that button, so render your own and call the scope's toggleSort to keep sorting reachable: @click="toggleSort" passes the event, so Shift adds the column there too.

Selecting rows

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

Select products to add to a purchase order
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 is a <fieldset>: give it a legend that says what the selection is for (it falls back to caption). Each checkbox is labelled from the row's cells in the locale's word order ("Select …" in English, "… auswählen" in German); accessible-label replaces that label with your own function of (columns, item).

  • name renders a hidden input per selected value, so the table submits inside a plain <form>.
  • selectable also takes a predicate (item) => boolean to lock rows, and max caps the count.
  • item:selected and item:unselected are for side effects; the model stays the state.

Select-all means "everything, across every page". While v-model:select-all is on, every row reads as selected and unticking one adds it to v-model:unselected-items, so your bulk endpoint receives { all: true, except: [...] }. modelValue stays empty in that mode and the hidden inputs post nothing, so read the two select-all models and submit through a handler.

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

Choose the replacement product
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

Coming from v2allow-select-all → disable-select-all

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 has no effect; a development build warns, and npx bitboss-ui check --fix inverts it.

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

Loading and empty

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

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 it draws skeleton rows, shaped per column by the skeleton field (text, avatar, avatar-text, image, badge). With rows already on screen they stay, dimmed, under a progress bar along the header, so a refetch never loses the reader's place. While loading, the header and rows are inert; interactive-while-loading keeps them usable during a background poll. loading-text is announced to screen readers, never painted.

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

Product
Category
On hand

No product matches "bookshelf".

#empty fills the table's own full-width cell, already centred and spanning every column, so pass content, not a row.

A provider that throws is a failed load: the rows clear and the empty state shows failed-text. #empty receives what was thrown as error (null when nothing failed), so it can offer a retry. Do not catch inside the provider and return []: that reads as an empty result. To retry, bump a counter listed in dependencies.

Coming from v2Skeletons, #loading and #empty

v2 replaced the rows with skeletons on every refetch; v3 keeps them. The #loading slot replaces the skeleton, so it renders on the first load only. #empty used to replace the empty-state <tr>: delete a hand-rolled <tr> / <td> and keep its content.

Pages and outside control

The table never slices. Hand it the current page as items and tell it where that page sits:

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

1–8 of 26

page, per-page and total-items let a screen reader announce each row's position in the whole result set, not just on this page. Keep the range line on one line (shrink-0 whitespace-nowrap) and let the pager take the rest of the row; it fits its buttons to what is left.

On a server-driven table the provider forwards filters, sort and page and returns the slice. Do not list the page in dependencies: a provider table already refetches when the shared page moves. enforce-coherence drops selected, highlighted and expanded values whose row left the result after each load, but leave it off on any provider-paginated table: every page turn is a load, so it would drop the selections held on other pages. Reconcile in the provider instead.

Give the table an id and useBbTableContext(id) hands any component on the page a two-way handle on its state, with no prop drilling:

Select products to discount or archive them.
Select products for a bulk update
Product
SKU
Price
Aeris Ergonomic Task Chair
FUR-CHR-1042
€489.00
Lumen Sit-Stand Desk 160
FUR-DSK-1187
€749.00
Meridian Footrest
FUR-FTR-1093
€59.90
Quill Whiteboard 120×90
FUR-WBD-1155
€179.00
Northlight 27" 4K Display
DIS-MON-3301
€549.00
Northlight 34" Ultrawide
DIS-MON-3318
€899.00

The handle exposes selected, unselected, all, highlighted, sort, order, page, perPage, totalItems and totalPages as writable refs, next to the v-models rather than instead of them. A BbPagination with a matching table-id joins the same state, and it renders the right page count on the server too. The id is a rendezvous key: the table, the handle and the pagination meet on that exact string, and a different spelling opens a separate state that no table fills. In development a BbPagination whose table-id matches no mounted table warns the first time someone uses it.

Detail rows and the open record

Two ways to show more of a record. Inline: track the open rows in v-model:expanded-items and render their detail in #expand, a full-width row below the record.

Reference
Customer
Total
Details
ORD-2026-0417
Diego Ramirez
€1,298.00
  • Lumen Sit-Stand Desk 160 × 1 at €749.00
  • Northlight 27" 4K Display × 1 at €549.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 lives in the actions column, labelled with actions-text, and binds the scope's expandProps: it wires aria-expanded, aria-controls and the click, so do not also bind @click="toggleExpanded" (one click would toggle twice). For a control of your own, the scope also hands you toggleExpanded and the read-only expanded flag. An icon-only toggle still needs an aria-label. All three reach every cell slot, so the chevron can sit in the identity column.

Beside the table: v-model:highlighted marks the one row whose details are open in a panel. It is single by design and separate from selection.

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

Binding the model turns highlight on. An unbound table only emits click:row. A highlighted row gets aria-current="true" and the shipped fill with a brand accent bar; clicking it again clears it. pt:row:highlighted (or row-class) paints it your way: the demo uses a stronger tint and medium-weight text.

highlightable decides which rows a click may open: false for none (a user without the privilege), or a function of the row's item. A refused row ignores clicks and Enter, and a value your code puts in the model still shows on it. Load the details in a watch on the model, not in click:row: the model also changes when the row closes.

vue
<BbTable
    v-model:highlighted="openTaskId"
    :highlightable="(task) => task.status !== 'archived'"
    pt:row:highlighted="font-medium"
    item-value="id"
    :items="tasks"
    :columns="columns"
/>

keyboard-navigation turns the table into an ARIA grid with one tab stop and focus that moves between cells:

  • ← → ↑ ↓ move one cell (↑ from the first row reaches the header); Home / End go to the row's ends, PageUp / PageDown ten rows.
  • Enter or Space opens the row, exactly like a click. On a cell that holds buttons or links, Enter or F2 moves into them and Escape comes back.
  • Shift+Space toggles the row's selection, Ctrl+A selects all.

With an #expand slot the table is a treegrid: → on a row expands it, ← collapses it. Controls inside cells leave the tab order while the grid is on, so Tab passes the table in one step. Focus and highlight stay separate: arrowing opens nothing until the reader presses Enter.

Row events fire with (event, item), where item is your record exactly as passed in items. A click on a control inside the row (a link, a button, a checkbox) emits none. Keep them for side effects: row navigation goes through a real link in a cell (a BbButton with href or to), so middle-click and open-in-new-tab work.

Coming from v2Row event payload

v2 passed the table's internal row wrapper (your record at row.item) and a third selected boolean. Both are gone; a three-parameter handler is a type error.

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

Reordering rows

Add reorderable-rows for a drag handle at the start of each row. People drag it, or focus it and lift the row with Space and the arrow keys. The table only reports the move; your handler splices the array.

Reorder
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

Order: FUR-CHR-1042, FUR-DSK-1187, FUR-FTR-1093, FUR-WBD-1155, DIS-MON-3301

@reorder:rows carries { item, from, to }: take the row out at from, put it back at to. A drop where the row already was emits nothing. Style the handle with pt:dragHandle (a class list, not a per-row function).

  • Server pages: the indexes are into the page the table holds, so add the page's offset, (page - 1) * perPage + from. A row cannot move to another page.
  • Sorted tables: a move is reported against the rows as sorted. Splicing your stored array changes nothing a sort shows, so the row snaps back. Sort by a rank the drag edits (priority, position) and write the new ranks, or turn the handles off for any other sort: :reorderable-rows="!sort.length || sort[0][0] === 'priority'".

Narrow, wide and long tables

On a narrow screen, hide columns rather than scroll: a scrolling table loses its identity column first, and that is the one the reader is looking for. Rank the columns, give each secondary one its own hidden rule, and keep at most three columns on a phone, with the facts that left stacked under the row's name. Resize the window to watch the columns go:

Order
Customer
Placed
Channel
Status
Total
ORD-2026-0417
Diego Ramirez
2 Jun
Web shop
Delivered
€1,298
ORD-2026-0418
Sofia Marchetti
5 Jun
Web shop
Delivered
€338.80
ORD-2026-0421
Rafael Costa
11 Jun
Reseller
Refunded
€899
ORD-2026-0426
Idris Bello
18 Jun
Web shop
Delivered
€477
ORD-2026-0430
Ingrid Halvorsen
24 Jun
Phone
Delivered
€2,196
ORD-2026-0435
Nikhil Raghavan
1 Jul
Web shop
Cancelled
€257

The column list is a computed. VueUse's useBreakpoints gives one ref per tier, and each column says inline when it leaves:

ts
import { computed } from 'vue';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';

const breakpoints = useBreakpoints(breakpointsTailwind, { ssrWidth: 1280 });
const isMobile = breakpoints.smaller('md');
const belowLg = breakpoints.smaller('lg');
const belowXl = breakpoints.smaller('xl');

const columns = computed((): BbTableColumn<Order>[] => [
    { key: 'reference', label: 'Order', rowHeader: true },
    { key: 'customerId', label: 'Customer', hidden: isMobile.value },
    { key: 'placedAt', label: 'Placed', hidden: belowLg.value },
    { key: 'channel', label: 'Channel', hidden: belowXl.value },
    { key: 'status', label: 'Status' },
    { key: 'total', label: 'Total', align: 'right' },
]);

ssrWidth makes the server and the first client render agree on a width, so a server-rendered page hydrates without a mismatch. A column that only makes sense on a phone takes the inverse rule, hidden: !isMobile.value.

hidden removes the column's track but keeps it declared, so it returns to its place in order when there is room again; never filter the array instead. Never hide cells with display: none either: the grid keeps the track and every following cell shifts one column over. If the table sits in a narrow card rather than a narrow screen, drive the same hidden from the card's width (VueUse useElementSize) instead of the window's.

When every column must stay reachable (a spreadsheet-shaped tool), let it scroll and pin what anchors the row. fixed-headers sticks the header, fixed: 'left' | 'right' on a column pins it, and compact tightens the rows.

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

7 columns. Scroll sideways: Product stays pinned and the header stays put. Drag a header to move it, or its edge to resize it.

Order: as declared

The table is its own scroll box: put the max-height on the table, as above, not on a wrapper. Inside a flex row, give the ancestor between page and table min-width: 0, or the row widens instead of letting the table scroll. The selection and actions columns pin with fixed-select and fixed-actions. Pinned cells are opaque and paint the table's --bg, so on a surface other than the panel colour, set that one knob.

Once the list is long, virtual mounts only the rows in view:

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.

Bound the height, or the page becomes the scroller; the header sticks on its own. Rows keep their absolute aria-rowindex and focus is never lost to an unmount. Columns are sized once, from the first rendered rows, and later longer values clip with an ellipsis, so give a column that must wrap a width. Browser find only reaches mounted rows.

To let people rearrange the grid, reorderable makes each header a drag handle writing v-model:order (reorderable: false on a column locks it in place), and resizable reports new widths through @resize:column (resizable: false on a column locks its width). The pinned catalogue above has both, with the pinned column locked.

Styling the grid: classes, roles and pt

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

The surface is --bg, a local on .bb-table defaulting to var(--bb-panel). Cells are transparent so the row can tint them, but the stuck header band and pinned cells are opaque and paint --bg; a pinned cell still takes its row's hover and highlight tint, yours from a pt:row hover class (hover:bg-…) included, so give an app hover colour as an opaque colour. Set --bg once when the table sits on another surface:

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

For one-off changes, pt reaches every part without a selector: pt:cell-column-header (every header cell), pt:row, pt:cell, pt:sort-icon, pt:select-icon, pt:dragHandle, pt:caption, and states such as pt:row:expanded, pt:row:disabled (a row selectable rejects), pt:cell-column-header:sorted and pt:body:empty. The full list is on the API page.

RegionClass · role
container.bb-table
caption.bb-table__caption (before the root)
grid root.bb-table__table · table; grid / treegrid with keyboard
header group.bb-table__head · rowgroup
header row.bb-table__header-row · row
header cell.bb-table__cell-column-header (+ --select, --actions) · columnheader
body group.bb-table__body · rowgroup
data row.bb-table__row (+ --highlighted, --expanded) · row
data cell.bb-table__cell (+ --select, --actions) · cell; gridcell with keyboard; rowheader
expand row.bb-table-expand__row > .bb-table-expand__cell · row > cell
empty state.bb-table-empty__row > .bb-table-empty__cell · row > cell
skeleton row.bb-table-skeleton__row > .bb-table-skeleton__cell · row > cell
footer group.bb-table__foot · rowgroup, only with #tfoot

The roles are supported hooks. A body cell can be cell, gridcell (under keyboard-navigation) or rowheader (a rowHeader column), so a rule that must hold everywhere uses .bb-table__cell or :is([role='cell'], [role='gridcell'], [role='rowheader']). Cells are direct children of their row, so :nth-child(n) addresses a column.

Some geometry is not yours to change: display: grid and gap: 0 on the root, row groups and rows; grid-column: 1 / -1 and contain: inline-size on full-width cells; white-space: nowrap on the actions cell; and position: sticky on .bb-table__head under fixed-headers.

Replacing a whole row group

#thead, #tbody and #tfoot hand you a region, and the markup you pass is grid markup. Reach for them only when a per-column slot does not fit, such as a note spanning the table. Write one <div role="row"> per row, holding one <div role="cell"> (or role="columnheader" in the head) per column. A spanning cell uses grid-column plus aria-colspan; #tfoot receives columnCount, selection and actions columns included, and the table numbers its rows after the body (leave aria-rowindex off). Nothing lints the roles: plain <div>s render fine and read as a pile of boxes to a screen reader. A custom #thead over an empty table publishes no column tracks, and a #tbody turns off virtual and the keyboard grid.

Coming from v2Element selectors and v2 classes

BbTable now renders role-complete <div> elements as a CSS grid. Props, slots, events and models did not change, but markup-dependent behaviour 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, and ellipsis works with no extra CSS. Remove any v2 max-width: 0 rule on cells: it now 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 locals tightened (--px 12px, --py 6px, --cell-h 36px, from 16px / 8px / 42px), and every published --bb-table-* variable lost its prefix (--bb-table-offset-start → --offset-start); --fill and --natural-width are gone. Grep your CSS for --bb-table-.

Translating a v2 stylesheet: table → .bb-table__table, thead → .bb-table__head, tbody → .bb-table__body, tfoot → .bb-table__foot, tr → [role='row'] (or .bb-table__row for data rows only), th → .bb-table__cell-column-header, td → .bb-table__cell, td:nth-of-type(n) → :nth-child(n). colgroup / col are gone: declare width on the column. [role='cell'] is stronger than td was, so an override that used to lose by one point can now win.

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.