NbDataTable is a presentational, data-driven table for read-mostly application lists. It ships full <table> semantics, controlled sorting, row selection, loading / empty / error states, density options, a sticky header with horizontal scroll, and a companion NbPagination footer.
It is deliberately not an editable spreadsheet. When cells need in-place editing, reach for NbSpreadsheet instead.
Basic Usage
Provide columns, rows, and a rowKey for identity. Cell values are read from row[column.key] by default.
<template>
<NbDataTable :columns="columns" :rows="rows" row-key="id" />
</template>
<script setup lang="ts">
import type { IDataTableColumn } from '@nubisco/ui'
interface Member {
id: number
name: string
role: string
status: string
}
const columns: IDataTableColumn<Member>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'role', header: 'Role', sortable: true },
{ key: 'status', header: 'Status', align: 'center' },
]
const rows: Member[] = [
{ id: 1, name: 'Ada Lovelace', role: 'Engineer', status: 'Active' },
{ id: 2, name: 'Grace Hopper', role: 'Admiral', status: 'Active' },
{ id: 3, name: 'Alan Turing', role: 'Researcher', status: 'Away' },
]
</script>Sorting
Sorting is controlled: the table renders the sortState you pass and emits a sort event with the next state when a sortable header is activated (click or keyboard). Direction cycles asc → desc → none. Apply the ordering yourself (or server-side) so the component stays honest about what it displays. Sortable headers expose aria-sort.
<template>
<NbDataTable
:columns="columns"
:rows="sortedRows"
row-key="id"
:sort-state="sortState"
@sort="onSort"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { IDataTableSortState } from '@nubisco/ui'
const sortState = ref<IDataTableSortState>({ key: 'name', direction: 'asc' })
function onSort(state: IDataTableSortState) {
sortState.value = state
}
const sortedRows = computed(() => {
const { key, direction } = sortState.value
if (direction === 'none') return rows
return [...rows].sort((a, b) => {
const cmp = String(a[key]).localeCompare(String(b[key]))
return direction === 'asc' ? cmp : -cmp
})
})
</script>Selection
Set selectable to 'single' or 'multiple' to render a leading control column, and bind the selected row keys with v-model:selected. The header "select all" checkbox (multiple mode) reflects an indeterminate state and preserves selections that live on other pages.
<template>
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
selectable="multiple"
v-model:selected="selected"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selected = ref<(string | number)[]>([])
</script>Toolbar, batch actions & row actions
The toolbar region hosts a title / description, a #search slot, and a #toolbar-actions slot. While rows are selected, a batch-action bar takes over the toolbar and renders the #batch-actions slot (with a clear helper). A trailing overflow column appears whenever a #row-actions slot is provided.
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
title="Team members"
description="People with access to this workspace."
selectable="multiple"
v-model:selected="selected"
>
<template #toolbar-actions>
<NbButton variant="primary" size="sm" icon="plus">Add member</NbButton>
</template>
<template #batch-actions="{ selectedKeys, clear }">
<NbButton variant="ghost" icon="trash" @click="clear">
Delete {{ selectedKeys.length }}
</NbButton>
</template>
<template #row-actions>
<NbButton variant="ghost" size="sm" icon="dots-three-vertical" />
</template>
</NbDataTable>Custom cells
Render custom cell content three ways, in precedence order: a #cell-<key> slot, a column render function, then the raw value. Headers can be overridden with a #header-<key> slot.
<template>
<NbDataTable :columns="columns" :rows="rows" row-key="id">
<!-- Slot approach: full template control -->
<template #cell-status="{ value }">
<NbBadge>{{ value }}</NbBadge>
</template>
</NbDataTable>
</template>
<script setup lang="ts">
import { h } from 'vue'
import type { IDataTableColumn } from '@nubisco/ui'
// Render-function approach: keep the definition next to the column
const columns: IDataTableColumn[] = [
{ key: 'name', header: 'Name' },
{
key: 'email',
header: 'Email',
render: (row) => h('a', { href: `mailto:${row.email}` }, row.email),
},
{ key: 'status', header: 'Status' },
]
</script>States
Loading renders shimmering skeleton rows (skeletonRows controls the count). error shows an error row that takes precedence over the empty state. When there are no rows, emptyMessage (or the #empty slot) is shown.
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
loading
:skeleton-rows="5"
/>
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
empty-message="No members yet"
/>
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
error="Failed to load members."
/>Density
size follows the shared sm / md / lg scale used across the library. Combine with zebra for alternating row backgrounds.
<NbDataTable :columns="columns" :rows="rows" row-key="id" size="sm" zebra />Pagination
Drop an NbPagination into the #footer slot. Both components are fully controlled, so the same wiring drives client-side slicing or server-side paging. On page-size change the page resets to 1.
<template>
<NbDataTable :columns="columns" :rows="pagedRows" row-key="id">
<template #footer>
<NbPagination
:page="page"
:page-size="pageSize"
:total="allRows.length"
v-model:page="page"
@update:page-size="onPageSize"
/>
</template>
</NbDataTable>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const page = ref(1)
const pageSize = ref(5)
function onPageSize(size: number) {
pageSize.value = size
page.value = 1
}
const pagedRows = computed(() => {
const start = (page.value - 1) * pageSize.value
return allRows.slice(start, start + pageSize.value)
})
</script>Fill height & internal scroll
Set fill and the table becomes a flex item that fills its parent and scrolls internally: the toolbar and the #footer stay pinned, the header stays sticky, and only the body scrolls. This is the shape you want for a full-height list view, and it needs no table-specific CSS on your side.
<template>
<NbDataTable
:columns="columns"
:rows="rows"
row-key="id"
title="Team members"
fill
>
<template #footer>
<NbPagination v-model:page="page" :page-size="pageSize" :total="total" />
</template>
</NbDataTable>
</template>The parent must bound the height
fill makes the table claim its parent's height. It cannot create one. The parent has to be a flex column that is itself bounded, which in practice means min-height: 0 somewhere up the chain. Without that, the parent grows to fit every row and the page scrolls instead of the body.
NbShell's content row already qualifies, so a table placed directly in #content works with no extra CSS:
// Already part of NbShell, shown here for reference.
.nb-shell__content-row {
flex: 1;
min-height: 0;
overflow: hidden;
}<NbShell>
<template #content>
<NbDataTable :columns="columns" :rows="rows" row-key="id" fill />
</template>
</NbShell>An unbounded wrapper between the shell and the table defeats it. A plain <div> grows to fit its content, so the table inherits no ceiling:
<!-- Does not scroll internally: the wrapper has no bounded height. -->
<template #content>
<div class="my-view">
<NbDataTable :columns="columns" :rows="rows" row-key="id" fill />
</div>
</template>Give that wrapper the same treatment and it works again:
.my-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}Leave fill off (the default) and the table sizes to its content exactly as before.
Accessibility
- Renders a real
<table>with<th scope="col">header cells and a<colgroup>for widths. - Sortable headers are
<button>s inside the<th>and exposearia-sort(ascending/descending/none), so sorting works with keyboard and screen readers. - Selection controls are labelled checkboxes / radios; selected rows carry
aria-selected. - The table exposes
aria-busywhile loading, and usesaria-label(fromariaLabelortitle).