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,BbCheckboxGrouporBbRadioGroup— options are being picked inside a form; a selectable table is not a form fieldBbTree— the data is genuinely recursive: a file tree, nested categories. Flat rows with one level of detail stay hereBbSelectPopover— it is a picker that opens from a cellBbPagination— 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.
An async orders list
Start with the shape most application screens need: an API-backed order list with filtering and server ordering.
<template>
<div class="flex flex-col gap-3">
<BbSelect
id="order-status-filter"
v-model="status"
class="max-w-sm"
clearable
:items="statusOptions"
label="Status"
multiple
name="status"
placeholder="Every status"
/>
<BbTable
v-model:sort="sortModel"
caption="Orders, filtered by status"
:columns="columns"
:compact="isMobile"
:dependencies="[status, sort]"
:deps-debounce-time="200"
item-value="id"
:items="fetchOrders"
loading-text="Loading orders…"
empty-text="No order has that status."
>
<!-- The slot name is the key in kebab-case: `customerId` is `#customer-id`.
`value` in the scope is the ROW's value (its `item-value`), so read
the cell's own field from `item`. -->
<template #customer-id="{ item }">
<span class="flex min-w-0 items-center gap-2">
<BbAvatar
:alt="userById[item.customerId]?.fullName"
size="lg"
:src="userById[item.customerId]?.avatar"
/>
<span class="truncate">{{ userById[item.customerId]?.fullName }}</span>
</span>
</template>
<template #status="{ content, item }">
<BbBadge size="sm" :variant="statusVariant[item.status]">{{ content }}</BbBadge>
</template>
</BbTable>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbBadge, BbSelect, BbTable, useMobile } from 'bitboss-ui';
import type { BadgeVariantType, BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { delay, orderStatusLabels, orders, orderStatuses, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// Fixed zone so the prerendered date matches the hydrated one.
const day = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
// On a phone the reference, status and total carry the row, in compact rows;
// the rest waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Order>[] => [
// `rowHeader` names each row for a screen reader: every cell is read with it.
{
key: 'reference',
label: 'Reference',
sortable: true,
rowHeader: true,
// An ID reads as one token: keep it on one line.
width: 'max-content',
},
{ key: 'customerId', label: 'Customer', hidden: isMobile.value },
{
key: 'status',
label: 'Status',
sortable: true,
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{ key: 'placedAt', label: 'Placed', sortable: true, formatter: (iso: string) => day.format(new Date(iso)), hidden: isMobile.value },
{
key: 'total',
label: 'Total',
align: 'right',
sortable: true,
formatter: euro.format,
},
]);
// Badge variants from a lookup, so the names stay type-checked.
const statusVariant: Record<OrderStatus, BadgeVariantType> = {
pending: 'outline',
processing: 'secondary',
shipped: 'primary',
delivered: 'secondary',
cancelled: 'destructive',
refunded: 'outline',
};
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
// The open orders: everything not yet delivered, cancelled or refunded.
const status = ref<OrderStatus[] | null>(['pending', 'processing', 'shipped']);
// Unsorted means "newest first" (the API default). One column at a time: keep
// only the entry the reader clicked last, so every header click reorders.
const sort = ref<BbTableSortEntry[]>([]);
const sortModel = computed({
get: () => sort.value,
set: (entries: BbTableSortEntry[]) => (sort.value = entries.slice(-1)),
});
const compareOrders = (left: Order, right: Order, key: string): number => {
const leftValue = left[key as keyof Order];
const rightValue = right[key as keyof Order];
if (typeof leftValue === 'number' && typeof rightValue === 'number') {
return leftValue - rightValue;
}
return String(leftValue).localeCompare(String(rightValue));
};
// Stands in for `api.orders.list({ status, orderBy: sort })`. Dependencies are
// the whole request wiring: no watcher, manual loading state, or race handling.
const fetchOrders = (): Promise<Order[]> =>
delay(
orders
.filter((order) => !status.value?.length || status.value.includes(order.status))
.toSorted((left, right) => {
if (!sort.value.length) return right.placedAt.localeCompare(left.placedAt);
for (const [key, direction] of sort.value) {
const comparison = compareOrders(left, right, key);
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
}
return 0;
}),
700
);
</script>
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:
<template>
<BbTable
caption="Workspace equipment catalogue"
:columns="visibleColumns"
item-value="id"
:items="catalogue"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
// A column is `{ key, label }`. `key` pulls the cell out of the record, so the
// rows go in exactly as the API returned them — nothing is pre-mapped.
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
// `max-content` keeps a SKU on one line; the name wraps instead.
{ key: 'sku', label: 'SKU', width: 'max-content' },
{ key: 'stock', label: 'Stock', align: 'right' },
];
// On a phone the name and stock carry the row; the SKU waits for a wider screen.
const { isMobile } = useMobile();
const visibleColumns = computed(() =>
columns.map((column) => ({ ...column, hidden: isMobile.value && column.key === 'sku' }))
);
const catalogue = products.slice(0, 6);
</script>
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.
<template>
<BbTable
caption="Orders placed in June"
:columns="columns"
item-value="id"
:items="recentOrders"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { orders, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const day = new Intl.DateTimeFormat('en-GB', {
dateStyle: 'medium',
// The timestamps are UTC: pin the zone so server and browser agree on the day.
timeZone: 'UTC',
});
// On a phone the reference and total carry the row; the rest waits for a
// wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Order>[] => [
// `rowHeader`: a screen reader announces the reference with every cell of its row.
{ key: 'reference', label: 'Reference', rowHeader: true, width: 'max-content' },
{
// The row stores a foreign key; the formatter resolves it. Denormalising
// a customer name into every order would be data the table invented.
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
placeholder: 'Unknown customer',
hidden: isMobile.value,
},
{
key: 'placedAt',
label: 'Placed',
formatter: (value: string) => day.format(new Date(value)),
hidden: isMobile.value,
},
{
// A virtual column: `lines` is on the record but `key` names nothing the
// table can extract, so the formatter computes the cell from `item`.
key: 'lineCount',
label: 'Lines',
align: 'center',
formatter: (_content, _key, item: Order) => item.lines.length,
},
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
]);
const recentOrders = orders.filter((order) => order.placedAt.startsWith('2026-06'));
</script>
keyextracts 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: akeythat matches no field, computed fromitem(the Lines column above).formatOnNull: falseopts out;placeholderfills a cell that still ends up empty.rowHeader: truemarks 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,tdClassandrowClassset 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:
<template>
<BbTable
caption="Stock levels, with depleted rows flagged"
:columns="columns"
item-value="id"
:items="catalogue"
:row-class="rowClass"
>
<!--
`#<key>` replaces the cell body. The column stays in `columns`: its
label, alignment and width still come from there.
-->
<template #status="{ content, item }">
<BbBadge size="sm" :variant="statusVariant[item.status]">
{{ content }}
</BbBadge>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbBadge, BbTable, useMobile } from 'bitboss-ui';
import type { BadgeVariantType, BbTableColumn } from 'bitboss-ui';
import type { Product, ProductStatus } from '~/demo-data';
import { products, productStatusLabels } from '~/demo-data';
// On a phone the name and its status carry the row; SKU and count wait for
// a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product', rowHeader: true },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{
// The formatter still runs: `content` reaches the slot already formatted,
// so the badge shows the label and the row keeps storing the code.
key: 'status',
label: 'Status',
formatter: (value: ProductStatus) => productStatusLabels[value],
},
{ key: 'stock', label: 'On hand', align: 'right', hidden: isMobile.value },
]);
// A lookup object, not a template-composed name: the variant names stay
// type-checked against the registry.
const statusVariant: Record<ProductStatus, BadgeVariantType> = {
'in-stock': 'secondary',
'low-stock': 'outline',
backorder: 'outline',
discontinued: 'destructive',
};
// `row-class` runs per record and lands on `.bb-table__row`. State that
// concerns the whole record belongs here, not in a cell slot that paints one
// column.
const rowClass = (product: Product) =>
product.stock === 0 ? 'bg-[color:var(--bb-muted)] text-[color:var(--bb-text-muted)]' : '';
const catalogue = products.filter((product) => product.stock < 20).slice(0, 6);
</script>
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:
<template>
<BbTable
v-model:sort="sort"
caption="Catalogue with an annotated header"
:columns="columns"
item-value="id"
:items="sorted"
>
<!--
`:prepend` renders BEFORE the column's existing header content, so the
built-in sort button survives and the right-aligned label stays flush
with its numbers. Replacing `#header:stock` outright would take the
button with it.
-->
<template #stock:header:prepend>
<BbButton
v-bb-tooltip="'Units across all warehouses'"
class="me-1"
icon="lucide:info"
size="xs"
variant="ghost"
>
About on-hand stock
</BbButton>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, useMobile, vBbTooltip } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
// On a phone the SKU waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product', sortable: true },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{ key: 'stock', label: 'On hand', align: 'right', sortable: true },
]);
const sort = ref<BbTableSortEntry[]>([]);
const catalogue = products.slice(0, 5);
// The table reports the sort; applying it is this code's job. Entries are in
// priority order.
const sorted = computed(() =>
catalogue.toSorted((left, right) => {
for (const [key, direction] of sort.value) {
const a = left[key as keyof Product];
const b = right[key as keyof Product];
const order =
typeof a === 'number' && typeof b === 'number'
? a - b
: String(a).localeCompare(String(b));
if (order !== 0) return direction === 'asc' ? order : -order;
}
return 0;
})
);
</script>
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.
<template>
<BbTable
:caption="`Lines on order ${order.reference}`"
:columns="columns"
item-value="productId"
:items="lines"
pt:last-row="font-medium"
>
<!--
`#<key>:last` fills one column's cell in a row after the data rows. The
cell sits on its column's own track, so the total stays under Amount
when columns move or hide. `items` is the rows in view.
-->
<template #product-id:last>Total</template>
<template #amount:last>{{ euro.format(total) }}</template>
</BbTable>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order, OrderLine } from '~/demo-data';
import { orders, productById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// On a phone the unit price waits for a wider screen; the totals row follows.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<OrderLine>[] => [
{
key: 'productId',
label: 'Product',
formatter: (id: number) => productById[id]?.name,
},
{ key: 'quantity', label: 'Qty', align: 'right' },
{
key: 'unitPrice',
label: 'Unit price',
align: 'right',
formatter: euro.format,
hidden: isMobile.value,
},
{
// A virtual column: the line total, so the footer sum sits under sums.
key: 'amount',
label: 'Amount',
align: 'right',
formatter: (_content, _key, line: OrderLine) =>
euro.format(line.quantity * line.unitPrice),
},
]);
// One order's lines, so the footer total is that order's total.
const order = orders.find((entry) => entry.reference === 'ORD-2026-0480') as Order;
const lines = order.lines;
const total = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
</script>
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.
- 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.
Sort model: [["category","asc"]]
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model:sort="sort"
caption="Catalogue, sortable by product, category and price"
:columns="columns"
:compact="isMobile"
item-value="id"
:items="sorted"
/>
<p class="text-sm opacity-70">
Sort model: <code>{{ JSON.stringify(sort) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product', sortable: true },
{ key: 'category', label: 'Category', sortable: true },
{
key: 'price',
label: 'Price',
align: 'right',
sortable: true,
formatter: euro.format,
},
];
// Compact rows on a phone, so the three sortable columns fit.
const { isMobile } = useMobile();
// Seeded, so the table opens pre-sorted. The column-level `sorted` field would
// only set `aria-sort`; the model is what moves the indicator.
const sort = ref<BbTableSortEntry[]>([['category', 'asc']]);
const catalogue = products.slice(0, 8);
// The table reports the model and nothing else. Applying it is this code's job —
// here client-side, on a server-driven table by forwarding it as `orderBy`.
const sorted = computed(() => {
if (sort.value.length === 0) return catalogue;
return [...catalogue].sort((left, right) => {
for (const [key, direction] of sort.value) {
const a = left[key as keyof Product];
const b = right[key as keyof Product];
const compared =
typeof a === 'number' && typeof b === 'number'
? a - b
: String(a).localeCompare(String(b));
if (compared !== 0) return direction === 'asc' ? compared : -compared;
}
return 0;
});
});
</script>
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.
Selected ids: none
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model="selected"
v-model:select-all="allPages"
v-model:unselected-items="excluded"
caption="Catalogue"
:columns="columns"
item-value="id"
:items="catalogue"
legend="Select products to add to a purchase order"
name="productIds"
select-all-label="Select every product"
selectable
/>
<p class="text-sm opacity-70">
<template v-if="allPages">
All products selected{{ excluded.length ? `, ${excluded.length} excluded` : '' }}. Post
<code>{ all: true, except: [{{ excluded.join(', ') }}] }</code>.
</template>
<template v-else>
Selected ids: <code>{{ selected.length ? selected.join(', ') : 'none' }}</code>
</template>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
// On a phone the SKU waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{ key: 'stock', label: 'On hand', align: 'right' },
]);
// `item-value="id"` is what puts ids in the model instead of whole rows, and
// what lets the selection survive a refetch that replaced every object.
const selected = ref<number[]>([]);
// The "all across pages" pair. In all-mode `selected` stays empty on purpose —
// read these two, never infer "everything is selected" from the model.
const allPages = ref(false);
const excluded = ref<number[]>([]);
const catalogue = products.slice(0, 6);
</script>
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).
namerenders a hidden input per selected value, so the table submits inside a plain<form>.selectablealso takes a predicate(item) => booleanto lock rows, andmaxcaps the count.item:selectedanditem:unselectedare 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:
Model: null
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model="replacement"
caption="Replacement candidates"
:columns="columns"
item-value="id"
:items="candidates"
legend="Choose the replacement product"
:multiple="false"
select-text="Choose"
selectable
/>
<p class="text-sm opacity-70">
Model: <code>{{ replacement ?? 'null' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// On a phone the category waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category', hidden: isMobile.value },
{
key: 'price',
label: 'Price',
align: 'right',
formatter: euro.format,
},
]);
// `:multiple="false"` renders radios and the model holds one value, or `null`.
// `select-text` names the radio column's header for screen readers (visually
// hidden); each radio is labelled from its row's cells.
const replacement = ref<number | null>(null);
const candidates = products.filter((product) => product.category === 'Displays');
</script>
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.
- <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.
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<BbButton :disabled="loading" size="sm" variant="outline" @click="firstLoad">
First load (no rows yet)
</BbButton>
<BbButton :disabled="loading" size="sm" variant="outline" @click="refetch">
Refetch (rows on screen)
</BbButton>
</div>
<BbTable
caption="Catalogue"
:columns="columns"
item-value="id"
:items="rows"
:loading="loading"
loading-text="Loading the catalogue…"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { delay, products } from '~/demo-data';
// On a phone the SKU waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product', skeleton: 'text' },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{ key: 'stock', label: 'On hand', align: 'right' },
]);
const catalogue = products.slice(0, 5);
const rows = ref<Product[]>([...catalogue]);
const loading = ref(false);
// Nothing to keep on screen: the table draws skeleton rows.
const firstLoad = async () => {
rows.value = [];
loading.value = true;
rows.value = await delay([...catalogue], 1600);
loading.value = false;
};
// Rows already on screen: they stay, dimmed and `inert`, under a progress bar.
// Try clicking a row while it runs — the body does not respond, on purpose.
const refetch = async () => {
loading.value = true;
await delay(null, 1600);
loading.value = false;
};
</script>
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.
<template>
<div class="flex flex-col gap-3">
<BbTextInput
id="catalogue-filter"
v-model="query"
label="Filter the catalogue"
name="q"
placeholder="Try monitor, or keyboard"
/>
<BbTable
caption="Catalogue, filtered"
:columns="columns"
item-value="id"
:items="filtered"
>
<!--
The slot fills the table's own full-width cell, already centred
and already spanning every column. Pass content, never a row.
-->
<template #empty>
<div class="flex flex-col items-center gap-2 py-4">
<p class="m-0 text-sm">No product matches "{{ query }}".</p>
<BbButton size="sm" variant="outline" @click="query = ''">
Clear the filter
</BbButton>
</div>
</template>
</BbTable>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, BbTextInput, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { matches, products } from '~/demo-data';
// On a phone the category waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category', hidden: isMobile.value },
{ key: 'stock', label: 'On hand', align: 'right' },
]);
const query = ref<string | null>('bookshelf');
const filtered = computed(() =>
products.filter((product) => matches(product.name, query.value ?? '')).slice(0, 6)
);
</script>
#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:
1–8 of 26
<template>
<div class="overflow-hidden rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]">
<BbTable
caption="Catalogue, eight products per page"
:columns="visibleColumns"
item-value="id"
:items="pageItems"
:page="page"
:per-page="perPage"
pt:body="min-h-[calc(8*(var(--cell-h)+1.4px))] content-start"
:total-items="products.length"
/>
<!-- The range line keeps to one line; the pager takes the rest of the row
and fits its buttons to it. No `justify-between`, no `flex-wrap`. -->
<div class="flex items-center gap-2 border-t border-[color:var(--bb-border)] px-3 py-2">
<p class="shrink-0 text-xs whitespace-nowrap text-[color:var(--bb-text-muted)] tabular-nums">
{{ from }}–{{ to }} of {{ products.length }}
</p>
<BbPagination
v-model="page"
label="Catalogue pages"
:per-page="perPage"
:total-items="products.length"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbPagination, BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product', rowHeader: true },
{ key: 'category', label: 'Category' },
{ key: 'stock', label: 'On hand', align: 'right' },
];
// On a phone the product and its stock carry the row.
const { isMobile } = useMobile();
const secondary = new Set(['category']);
const visibleColumns = computed(() =>
columns.map((column) => ({ ...column, hidden: isMobile.value && secondary.has(column.key) }))
);
const perPage = 8;
const page = ref(1);
const from = computed(() => (page.value - 1) * perPage + 1);
const to = computed(() => Math.min(page.value * perPage, products.length));
// The body keeps the height of a full page, so the short last page does not
// pull the pager up under the pointer.
// The table never slices. You hand it the current page; `page` / `per-page` /
// `total-items` are what let it announce the rows behind the ones on screen.
const pageItems = computed(() => products.slice(from.value - 1, to.value));
</script>
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:
<template>
<div class="flex flex-col gap-3">
<!-- Outside the table's subtree. No props and no refs travel between them. -->
<div
class="flex min-h-11 flex-wrap items-center gap-2 rounded-[var(--bb-radius)] px-3 py-1.5 text-sm"
:class="selectedCount ? 'bg-[var(--bb-muted)]' : ''"
>
<template v-if="selectedCount">
<span class="me-auto font-medium">{{ selectedCount }} of {{ catalogue.length }} selected</span>
<BbButton prepend:icon="lucide:percent" size="sm" variant="outline" @click="discount">
Discount 10%
</BbButton>
<BbButton prepend:icon="lucide:archive" size="sm" variant="outline" @click="archive">
Archive
</BbButton>
<BbButton size="sm" variant="ghost" @click="clearSelection">Clear</BbButton>
</template>
<span v-else class="opacity-70">Select products to discount or archive them.</span>
</div>
<BbTable
id="catalogue-table"
caption="Catalogue"
:columns="columns"
item-value="id"
:items="catalogue"
legend="Select products for a bulk update"
selectable
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, useBbTableContext, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// On a phone the SKU waits for a wider screen; the name and price stay.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{ key: 'price', label: 'Price', align: 'right', formatter: euro.format },
]);
// The table's `id` is the rendezvous key. A typo here does not error — it
// silently creates a second state node that syncs with nothing.
const table = useBbTableContext('catalogue-table');
const catalogue = ref<Product[]>(products.slice(0, 6).map((p) => ({ ...p })));
// `selected` holds item values (`item-value="id"`), not rows.
const selectedIds = computed(() => (table.selected.value ?? []) as number[]);
const selectedCount = computed(() => selectedIds.value.length);
const clearSelection = () => (table.selected.value = []);
const discount = () => {
catalogue.value = catalogue.value.map((p) =>
selectedIds.value.includes(p.id) ? { ...p, price: Math.round(p.price * 90) / 100 } : p
);
};
const archive = () => {
catalogue.value = catalogue.value.filter((p) => !selectedIds.value.includes(p.id));
clearSelection();
};
</script>
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.
<template>
<BbTable
v-model:expanded-items="expanded"
actions-text="Details"
caption="Orders, with their lines on demand"
:columns="visibleColumns"
:compact="isMobile"
item-value="id"
:items="recentOrders"
>
<!-- The #actions slot creates the actions column. `expandProps` wires
aria-expanded, aria-controls and the click: no @click of your own. -->
<template #actions="{ expanded: isOpen, expandProps }">
<BbButton
v-bind="expandProps"
:append:icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"
size="sm"
variant="ghost"
>
Lines
</BbButton>
</template>
<!-- One full-width row below the record it belongs to. -->
<template #expand="{ item }">
<div class="bg-[color:var(--bb-muted)] px-3 py-2.5 text-sm">
<!-- Money stays in the Total column; the lines read as text. -->
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="line in item.lines" :key="line.productId">
{{ productById[line.productId]?.name }}
<span class="text-[color:var(--bb-text-muted)]">
× {{ line.quantity }} at {{ euro.format(line.unitPrice) }}
</span>
</li>
</ul>
</div>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { orders, productById, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', rowHeader: true, width: 'max-content' },
{
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
},
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
];
// On a phone the customer waits for a wider screen and the rows go compact,
// so the toggle stays in view.
const { isMobile } = useMobile();
const visibleColumns = computed(() =>
columns.map((column) => ({ ...column, hidden: isMobile.value && column.key === 'customerId' }))
);
// The open rows, keyed by `item-value` — ids, so expansion survives a refetch.
const expanded = ref<number[]>([9001]);
const recentOrders = orders.slice(0, 5);
</script>
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.
<template>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<!-- `min-width: 0` lets this flex item shrink, so the table scrolls itself. -->
<div class="min-w-0 grow">
<!-- `keyboard-navigation` makes the table an ARIA grid with one tab stop:
the arrows move between cells, Enter or Space opens the row.
`pt:row:highlighted` tints the row the panel is showing. -->
<BbTable
v-model:highlighted="openOrder"
caption="Orders; open one to preview it"
:columns="columns"
:compact="isMobile"
item-value="id"
:items="recentOrders"
keyboard-navigation
pt:row:highlighted="bg-[color:color-mix(in_oklab,var(--bb-primary)_14%,var(--bb-panel))] font-medium"
/>
</div>
<aside
aria-live="polite"
class="w-full shrink-0 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3 text-sm sm:w-52"
>
<template v-if="active">
<p class="m-0 font-medium">{{ active.reference }}</p>
<p class="mt-0.5 mb-2 text-xs text-[color:var(--bb-text-muted)]">
{{ userById[active.customerId]?.fullName }}, {{ active.channel }} order
</p>
<ul class="m-0 flex list-none flex-col gap-1 p-0 text-xs">
<li v-for="line in active.lines" :key="line.productId" class="flex gap-2">
<span class="grow truncate">{{ productById[line.productId]?.name }}</span>
<span class="text-[color:var(--bb-text-muted)]">×{{ line.quantity }}</span>
</li>
</ul>
<p class="mt-2 mb-0 flex justify-between border-t border-[color:var(--bb-border)] pt-2 text-xs">
<span>Total</span>
<span class="font-medium tabular-nums">{{ euro.format(active.total) }}</span>
</p>
</template>
<p v-else class="m-0 text-xs text-[color:var(--bb-text-muted)]">
Click a row, or tab into the table and press Enter.
</p>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { orderById, orderStatusLabels, orders, productById, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
// `max-content` keeps the reference on one line on a phone; the rest give way.
{ key: 'reference', label: 'Reference', rowHeader: true, width: 'max-content' },
{
key: 'status',
label: 'Status',
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
];
// Compact rows on a phone, so the three columns fit.
const { isMobile } = useMobile();
// Binding `v-model:highlighted` turns highlight on; `null` means no row is open.
const openOrder = ref<number | null>(9002);
const active = computed(() =>
openOrder.value === null ? null : (orderById[openOrder.value] ?? null)
);
const recentOrders = orders.slice(0, 6);
</script>
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.
<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.
- <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.
Order: FUR-CHR-1042, FUR-DSK-1187, FUR-FTR-1093, FUR-WBD-1155, DIS-MON-3301
<template>
<div class="flex flex-col gap-2">
<BbTable
caption="Restock queue, most urgent first"
:columns="columns"
item-value="id"
:items="queue"
reorderable-rows
@reorder:rows="move"
/>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
Order: <code>{{ queue.map((product) => product.sku).join(', ') }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
// On a phone the name and stock carry the row; the SKU waits for a wider screen.
const { isMobile } = useMobile();
const columns = computed((): BbTableColumn<Product>[] => [
{ key: 'name', label: 'Product', rowHeader: true },
{ key: 'sku', label: 'SKU', width: 'max-content', hidden: isMobile.value },
{ key: 'stock', label: 'On hand', align: 'right' },
]);
const queue = ref<Product[]>(products.slice(0, 5));
// The table reports the move and never touches `items`. `from` and `to` index
// the rows it holds: take the row out at `from`, put it back at `to`.
function move({ from, to }: { from: number; to: number }) {
const next = [...queue.value];
next.splice(to, 0, ...next.splice(from, 1));
queue.value = next;
}
</script>
@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:
<template>
<!-- Resize the window: Channel leaves below xl, Placed below lg, Customer
below md. On a phone three columns remain and the order cell carries
the customer and the date. -->
<BbTable
caption="Recent orders"
:columns="columns"
:compact="isMobile"
item-value="id"
:items="recentOrders"
>
<template #reference="{ item }">
<span class="font-medium whitespace-nowrap">{{ item.reference }}</span>
<span v-if="isMobile" class="block text-xs text-[color:var(--bb-text-muted)]">
{{ userById[item.customerId]?.fullName }} ·
<span class="whitespace-nowrap">{{ day.format(new Date(item.placedAt)) }}</span>
</span>
</template>
<template #status="{ content, item }">
<BbBadge size="sm" :variant="statusVariant[item.status]">{{ content }}</BbBadge>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
import { BbBadge, BbTable } from 'bitboss-ui';
import type { BadgeVariantType, BbTableColumn } from 'bitboss-ui';
import type { Order, OrderChannel, OrderStatus } from '~/demo-data';
import { orderStatusLabels, orders, userById } from '~/demo-data';
// `ssrWidth`: the server and the first client render assume a 1280px window,
// so hydration matches; the real width takes over once the page is mounted.
const breakpoints = useBreakpoints(breakpointsTailwind, { ssrWidth: 1280 });
const isMobile = breakpoints.smaller('md');
const belowLg = breakpoints.smaller('lg');
const belowXl = breakpoints.smaller('xl');
// Whole amounts drop their .00, which buys the phone layout its last few pixels.
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
trailingZeroDisplay: 'stripIfInteger',
});
// The timestamps are UTC: pin the zone so server and browser agree on the day.
const day = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
const channelLabels: Record<OrderChannel, string> = {
web: 'Web shop',
phone: 'Phone',
reseller: 'Reseller',
marketplace: 'Marketplace',
};
// Each column states when it leaves. A hidden column stays declared, so it
// comes back in its place when the window widens.
const columns = computed((): BbTableColumn<Order>[] => [
{ key: 'reference', label: 'Order', rowHeader: true },
{
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
hidden: isMobile.value,
},
{
key: 'placedAt',
label: 'Placed',
formatter: (iso: string) => day.format(new Date(iso)),
hidden: belowLg.value,
},
{
key: 'channel',
label: 'Channel',
formatter: (channel: OrderChannel) => channelLabels[channel],
hidden: belowXl.value,
},
{ key: 'status', label: 'Status', formatter: (status: OrderStatus) => orderStatusLabels[status] },
{ key: 'total', label: 'Total', align: 'right', formatter: euro.format },
]);
const statusVariant: Record<OrderStatus, BadgeVariantType> = {
pending: 'outline',
processing: 'secondary',
shipped: 'primary',
delivered: 'secondary',
cancelled: 'destructive',
refunded: 'outline',
};
const recentOrders = orders.slice(0, 6);
</script>
The column list is a computed. VueUse's useBreakpoints gives one ref per
tier, and each column says inline when it leaves:
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.
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
<template>
<div class="flex flex-col gap-2">
<!--
The bound height goes on the table: `.bb-table` is already a scroll box,
so once its height is capped it becomes the scrollport the stuck header
and the pinned column resolve against.
-->
<BbTable
v-model:order="order"
caption="Catalogue — the header sticks, the first column is pinned"
class="max-h-72 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
:columns="columns"
compact
fixed-headers
item-value="id"
:items="products"
reorderable
resizable
/>
<p class="m-0 text-sm text-[color:var(--bb-text-muted)]">
{{ columns.length }} columns. Scroll sideways: Product stays pinned and the header stays put.
Drag a header to move it, or its edge to resize it.
</p>
<p class="m-0 text-xs text-[color:var(--bb-text-muted)]">
Order: <code>{{ order.length ? order.join(', ') : 'as declared' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product, ProductStatus } from '~/demo-data';
import { products, productStatusLabels } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// Fixed zone so the prerendered date matches the hydrated one.
const day = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
// Enough columns to overflow the page's content column, so both the sticky
// header and the pinned identity column have something to do.
const columns: BbTableColumn<Product>[] = [
// The pin lives on the column; `reorderable: false` keeps it first.
{ key: 'name', label: 'Product', width: 240, fixed: 'left', reorderable: false },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'category', label: 'Category', width: 160 },
{ key: 'price', label: 'Price', align: 'right', width: 120, formatter: euro.format },
{ key: 'stock', label: 'On hand', align: 'right', width: 110 },
{
key: 'status',
label: 'Status',
width: 140,
formatter: (value: ProductStatus) => productStatusLabels[value],
},
{
key: 'updatedAt',
label: 'Updated',
width: 110,
formatter: (iso: string) => day.format(new Date(iso)),
},
];
// Every header drag writes the full key order back here.
const order = ref<string[]>([]);
</script>
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:
5000 rows; a few dozen are in the DOM at any moment.
<template>
<div class="flex flex-col gap-2">
<BbTable
caption="Every stock movement of the year"
class="max-h-80 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
:columns="visibleColumns"
compact
item-value="id"
:items="movements"
virtual
/>
<p class="text-sm opacity-70">
{{ movements.length }} rows; a few dozen are in the DOM at any moment.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbTable, useMobile } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import { products } from '~/demo-data';
interface Movement {
id: number;
reference: string;
product: string;
sku: string;
quantity: number;
}
const columns: BbTableColumn<Movement>[] = [
{ key: 'reference', label: 'Movement', width: 120 },
// Unsized: it takes what the fixed columns leave, so all four fit.
{ key: 'product', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'quantity', label: 'Qty', align: 'right', width: 64 },
];
// On a phone the SKU waits for a wider screen, and Product gets a width so it
// clips with an ellipsis instead of pushing Qty out: virtual columns are
// measured once.
const { isMobile } = useMobile();
const visibleColumns = computed(() =>
columns.map((column) => ({
...column,
hidden: isMobile.value && column.key === 'sku',
width: isMobile.value && column.key === 'product' ? 110 : column.width,
}))
);
// Built once, from the catalogue, with no clock and no random source — the
// prerendered HTML and the hydrated markup have to be identical.
const movements: Movement[] = Array.from({ length: 5000 }, (_, index) => {
const product = products[index % products.length]!;
return {
id: index,
reference: `MOV-${String(index + 1).padStart(5, '0')}`,
product: product.name,
sku: product.sku,
quantity: (index % 12) + 1,
};
});
</script>
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:
.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.
| Region | Class · 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,#tbodyand#tfootrequire<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.
captionis 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: 0rule 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 (
--px12px,--py6px,--cell-h36px, from16px/8px/42px), and every published--bb-table-*variable lost its prefix (--bb-table-offset-start→--offset-start);--filland--natural-widthare 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.