Server-side paginated table with sortable headers, optional virtual scrolling, and single / multiple row selection. Columns are data (DatatableColumnDef[]); the table is fetch-driven — provide a DatatableFetch callback that resolves { data, totalRecords }, and the table handles paging UI, sort state, and (optionally) the virtual viewport. A *bsDatatableRow template renders a row's cells.
It has no shadow root. Anything you render into it — row templates, node templates, cell renderers — stays in the document, so your app's stylesheet, your own component styles and Bootstrap's utility classes reach it normally.
Its own CSS is scoped at build time onto a data-mps attribute, the same device Angular uses for _ngcontent, so it cannot leak out onto your markup.
The trade-off runs the other way: page CSS now reaches this component's internals, exactly as emulated encapsulation behaves everywhere else. ::part() and ::slotted() no longer address it — use ordinary CSS selectors instead.
| Loading… | |||
<bs-datatable pagination [columns]="columns" [fetch]="fetchArtists"
[(sortColumns)]="sortColumns" [perPageOptions]="[10, 20, 50]">
<ng-container *bsDatatableRow="let artist">
<td class="text-nowrap">{{ artist.name }}</td>
<td class="text-nowrap">{{ artist.yearStarted }}</td>
<td class="text-nowrap">{{ artist.yearQuit }}</td>
</ng-container>
</bs-datatable>
import { Component, inject, signal } from '@angular/core';
import {
BsDatatableComponent,
BsDatatableRowDirective,
type DatatableColumnDef,
type DatatableFetch,
type SortColumn,
} from '@mintplayer/ng-bootstrap/datatable';
import { Artist } from './artist';
import { ArtistService } from './artist.service';
@Component({
selector: 'my-artists',
templateUrl: './my-artists.component.html',
imports: [BsDatatableComponent, BsDatatableRowDirective],
})
export class MyArtistsComponent {
private artistService = inject(ArtistService);
// name is the sort key the fetch receives; label is the header text.
columns: DatatableColumnDef[] = [
{ name: 'Name', label: 'Artist' },
{ name: 'YearStarted', label: 'Year started' },
{ name: 'YearQuit', label: 'Year quit' },
];
sortColumns = signal<SortColumn[]>([]);
// Resolves { data, totalRecords }; req carries page, perPage,
// sortColumns and an AbortSignal.
fetchArtists: DatatableFetch<Artist> = (req) => this.artistService.pageArtists(req);
}
<!-- Every column sorts unless it says sortable: false. Clicking a header
cycles ascending, descending, off; the fetch callback receives
req.sortColumns, and [(sortColumns)] reads and writes it. -->
<bs-datatable [columns]="columns" [fetch]="fetchArtists" [(sortColumns)]="sortColumns">
<ng-container *bsDatatableRow="let artist">
<td>{{ artist.name }}</td>
<td>{{ artist.yearStarted }}</td>
<td>{{ artist.yearQuit }}</td>
</ng-container>
</bs-datatable>
Drag the line at the right edge of a header, or focus it and use the keyboard. A tap or click on it, without dragging, opens the resize options instead.
| Loading… | ||
<!-- columns: [
{ name: 'Name', label: 'Artist' },
{ name: 'YearStarted', label: 'Year started' },
// Icon-only header: label names it; resizable: false drops its handle.
{ name: 'actions', label: 'Actions', sortable: false, resizable: false },
] -->
<bs-datatable [columns]="columns" [fetch]="fetchArtists" pagination>
<ng-container *bsDatatableHeader="let column">
@if (column.name === 'actions') {
<span aria-hidden="true">⋯</span>
} @else {
{{ column.label }}
}
</ng-container>
<ng-container *bsDatatableRow="let artist">
<td>{{ artist.name }}</td>
<td>{{ artist.yearStarted }}</td>
<td>
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="inspect(artist)">Details</button>
</td>
</ng-container>
</bs-datatable>
With fill the table fits a parent with a fixed height, such as a dashboard tile. The header stays at the top and the pagination at the bottom; only the rows scroll in between. Horizontally the whole table scrolls as one.
| Loading… | |
<!-- The parent needs a definite height. -->
<div style="height: 225px">
<bs-datatable fill pagination [columns]="columns" [fetch]="fetchArtists">
<ng-container *bsDatatableRow="let artist">
<td>{{ artist.name }}</td>
<td>{{ artist.yearStarted }}</td>
</ng-container>
</bs-datatable>
</div>
fill combines with [virtualScroll]: the parent's height replaces the default virtual viewport, and only the rows in view are rendered and fetched.
| Loading… | |
<!-- The parent's height replaces the default virtual viewport. Rows still
loading render as placeholders, without calling the template. -->
<div style="height: 225px">
<bs-datatable fill [virtualScroll]="true" [itemSize]="40" [columns]="columns"
[fetch]="fetchArtists" [perPage]="25" [rowKey]="rowKey">
<ng-container *bsDatatableRow="let artist">
<td>{{ artist.name }}</td>
<td>{{ artist.yearStarted }}</td>
</ng-container>
</bs-datatable>
</div>
Two selection styles. In the default multiple mode a row click selects (Ctrl+click toggles, Shift+click selects a range) and then emits (rowClick), which is what the Basic usage table above does. In checkbox mode, below, a row click only opens the row: selecting happens in the checkbox cell alone, anywhere inside it.
The selection is keyed by [rowKey], so it survives paging, sorting and reload(): tick rows here, move to another page, sort, and the count keeps every key. With [fetch] that key must be stable, taken from the row itself and never from its position. [rowLabel] names each row checkbox for assistive technology ("Select Coldplay").
| Loading… | |||
Selected keys: —
<!-- selectionMode = 'none' | 'single' | 'multiple' | 'checkbox'.
'checkbox': a row click only opens the row (rowClick); the checkbox
cell alone selects. Enter opens, Space toggles.
The selection is the row KEYS, from [rowKey], and survives paging,
sorting and reload(). With [fetch] the key MUST be stable — taken from
the row, never from its position. (selectionChanged) also hands over
the selected rows. -->
<button type="button" (click)="table.reload()">Reload</button>
<bs-datatable #table
[columns]="columns"
[fetch]="fetchArtists"
pagination
selectionMode="checkbox"
[rowKey]="rowKey"
[rowLabel]="rowLabel"
[(selectedIds)]="selection"
(rowClick)="opened.set($event.row)">
<!-- row template as above -->
</bs-datatable>
selection = signal<string[]>([]);
opened = signal<Artist | null>(null);
// Identity: a stable key per row. Required with [fetch].
rowKey = (a: unknown) => String((a as Artist).id);
// Names each row checkbox for assistive technology: "Select Coldplay".
rowLabel = (a: unknown) => (a as Artist).name;
With [virtualScroll] + [fetch] the table fetches only the pages whose rows are in (or near) the viewport — against the same real, server-paged artist endpoint as the basic section. Scroll and watch the fetch log below: it never drains the whole result set up front; placeholder rows hold the scroll position until each window arrives. Sorting or changing the page size drops the cached windows and refetches the visible range.
Pages fetched: — (0 so far)
| Loading… | ||
<!-- Virtual + fetch: the table fetches only the pages whose rows are in
(or near) the viewport, keyed by [perPage]. Placeholder rows hold the
scroll position until each window arrives; the row template is not
called for them. The [fetch] contract is the same as paged mode. -->
<bs-datatable
[virtualScroll]="true"
[itemSize]="40"
[columns]="columns"
[fetch]="fetchWindowedArtists"
[perPage]="25"
[rowKey]="rowKey">
<ng-container *bsDatatableRow="let artist">
<td>{{ artist.name }}</td>
<td>{{ artist.yearStarted }}</td>
<td>{{ artist.yearQuit }}</td>
</ng-container>
</bs-datatable>
// The same [fetch] callback as paged, non-virtual mode — page + perPage.
// In flat virtual mode the table calls it once per *needed page* as the
// user scrolls, instead of draining every page up front.
fetchWindowedArtists: DatatableFetch<Artist> = (req) =>
this.artistService.pageArtists(req); // resolves { data, totalRecords }
Click a chevron to expand a row. Children load lazily on first expand and stay cached for subsequent collapse/expand cycles. Placeholder rows reserve viewport space while the fetch is in flight so the scrollbar stays accurate. Cascading selection: checking a parent selects all currently-loaded descendants; partially-selected parents render an indeterminate state.
| Loading… | ||||
<!-- Tree mode: virtual scrolling composes with nested expandable rows.
Each expanded row's children load lazily on demand; placeholders
reserve viewport space until the children arrive so the scrollbar
stays accurate. No row template: a cell template restyles one column
and the others keep their text. -->
<bs-datatable class="flex-grow-1"
[virtualScroll]="true"
[itemSize]="40"
[tree]="true"
[idKey]="treeIdKey"
[childCountKey]="treeChildCountKey"
[columns]="treeColumns"
[fetch]="fetchTreeItems"
[perPage]="100"
[(expandedIds)]="treeExpanded"
[rowKey]="treeRowKey"
selectionMode="multiple"
selectionStrategy="cascading"
[(selectedIds)]="treeSelection">
<ng-container *bsDatatableCell="let row; let column = column">
@if (column.name === 'code') {
<span class="font-monospace small">{{ row.code }}</span>
} @else {
{{ row[column.name] }}
}
</ng-container>
</bs-datatable>
import { Component, inject, signal } from '@angular/core';
import {
BsDatatableComponent,
BsDatatableCellDirective,
type DatatableColumnDef,
type DatatableFetch,
} from '@mintplayer/ng-bootstrap/datatable';
import { TreeItem } from './tree-item';
import { TreeItemService } from './tree-item.service';
@Component({
selector: 'org-tree',
templateUrl: './org-tree.component.html',
imports: [BsDatatableComponent, BsDatatableCellDirective],
})
export class OrgTreeComponent {
private treeItemService = inject(TreeItemService);
treeColumns: DatatableColumnDef[] = [
{ name: 'name', label: 'Name' },
{ name: 'code', label: 'Code' },
{ name: 'headcount', label: 'Headcount' },
];
treeExpanded = signal<unknown[]>([]);
treeSelection = signal<string[]>([]);
// Single callback for both roots and children — branches on parentId.
fetchTreeItems: DatatableFetch<TreeItem> = this.treeItemService.fetch;
treeRowKey = (item: unknown) => String((item as TreeItem).id);
treeIdKey: keyof TreeItem = 'id';
treeChildCountKey: keyof TreeItem = 'childCount';
}
// tree-item.service.ts — branches on req.parentId for roots vs children.
@Injectable({ providedIn: 'root' })
export class TreeItemService {
private readonly http = inject(HttpClient);
fetch: DatatableFetch<TreeItem> = async (req: DatatableFetchRequest) => {
const url = req.parentId == null
? '/api/treeItems'
: `/api/treeItems/${req.parentId}/children`;
const params = new HttpParams()
.set('page', String(req.page))
.set('perPage', String(req.perPage));
const r = await firstValueFrom(this.http.get<PagedResult<TreeItem>>(url, { params }));
return { data: r.items, totalRecords: r.totalCount };
};
}
Mark a column filterable and the table grows a second header row with a dropdown trigger in that column. Columns without it get an empty, correctly-sized cell, so the row stays aligned. No filterable column, no second row at all — toggle the checkbox below to watch the row appear and disappear.
A filterable column gets a built-in panel for free: a search box, an include/exclude toggle, a checkbox list of the column's distinct values, and a clear button. It lives in the shared core, so React and Vue get the same panel with no code of their own. The Name column below uses it as-is, with its values listed by the server ([distincts]).
A checkbox list is the wrong question for a quantity, so a column can ask a different one: filterMode: 'comparison' renders an operator (=, ≠, <, ≤, >, ≥) and a single input, with filterInputType choosing number (the default), date (a calendar day) or datetime (typed in local time, sent as a UTC instant) — the Created column below. Still the built-in panel: no template and no styling of your own. Narrow the operator list with filterOperators when only some make sense.
Quantities only — there is no text input type and no contains operator. Comparing strings is either exact match, which the value list already does better, or a lexicographic >, which is almost never what anyone means. A free-text filter is a nest-your-own case, below.
The consumer picks the mode per column and the component never infers it: a numeric column is often an enum and a string column is often ordinal, so any guess would be wrong about half the time.
When neither built-in question fits, set filterMode: 'custom' on the column and render the panel in a *bsDatatableFilter template — the Country column does. The template receives the column and a context with the loaded values and the search, apply and clear commands. That markup is yours, so you style it yourself: Bootstrap's .form-control is only styled inside bs-* components in this workspace.
The panel is a native popover in the top layer, so it is not clipped by the table's scroll container and is not hidden behind the sticky header — this demo runs in virtual-scroll mode, where both would otherwise happen.
Filtering happens on the server. The table keeps the active filters in a two-way [(filters)] model and sends them, with your own [fetchParams], in every fetch and distincts request; it never filters rows itself. With stateKey, the server render fetches the first page (filters included) and hands it to the browser, so the table hydrates with rows and fetches nothing — view this page with JavaScript off to see it.
Because the browser skips fetch for that first page, anything else your fetch reads off its response (the columns a server query decides, say) belongs in the response's extra, which must be JSON. (pageLoad) hands it back for every page the table applies, with seeded: true for the page that came from the server. Record it in fetch too: (pageLoad) fires in the browser only.
Keyboard:Tab reaches each filter trigger; Enter or Space opens the panel and moves focus to its search box; Tab cycles within the open panel; Esc closes it and returns focus to the trigger. Clearing a filter moves focus back to the search box, because the clear button disables itself.
multiple) mode:rowClick) — exactly like a click.checkbox mode:rowClick) and never selects it.The table owns the filter state. A panel is created on its first open and kept after that, so what you ticked, typed or scrolled to is still there when you reopen it — also when new rows arrive, because the value list is keyed by value. A write to filters from outside updates the ticks in place (and re-lists an open panel's values). Bind it one-way and set filtersReadonly, and the page can still set filters while the panels open with their controls disabled.
| Customer 0002 | customer0002@example.com | BE | 19 Dec 2024, 16:58 UTC |
| Customer 0003 | customer0003@example.com | BE | 5 Sept 2025, 16:58 UTC |
| Customer 0005 | customer0005@example.com | BE | 24 Apr 2024, 16:58 UTC |
| Customer 0006 | customer0006@example.com | NL | 10 Sept 2025, 16:58 UTC |
| Customer 0008 | customer0008@example.com | NL | 7 Sept 2025, 16:58 UTC |
| Customer 0011 | customer0011@example.com | NL | 24 Dec 2025, 16:58 UTC |
| Customer 0012 | customer0012@example.com | BE | 19 Jun 2024, 16:58 UTC |
| Customer 0014 | customer0014@example.com | BE | 13 Jun 2024, 16:58 UTC |
| Customer 0015 | customer0015@example.com | BE | 1 Dec 2023, 16:58 UTC |
| Customer 0017 | customer0017@example.com | BE | 14 Apr 2026, 16:58 UTC |
| Customer 0018 | customer0018@example.com | NL | 19 Oct 2024, 16:58 UTC |
| Customer 0022 | customer0022@example.com | BE | 29 Apr 2026, 16:58 UTC |
| Customer 0023 | customer0023@example.com | BE | 4 Apr 2024, 16:58 UTC |
| Customer 0025 | customer0025@example.com | NL | 13 Apr 2024, 16:58 UTC |
| Customer 0026 | customer0026@example.com | NL | 15 Aug 2024, 16:58 UTC |
| Customer 0027 | customer0027@example.com | BE | 11 Apr 2026, 16:58 UTC |
| Customer 0029 | customer0029@example.com | BE | 31 Mar 2025, 16:58 UTC |
| Customer 0031 | customer0031@example.com | NL | 12 May 2026, 16:58 UTC |
| Customer 0032 | customer0032@example.com | NL | 31 Aug 2025, 16:58 UTC |
| Customer 0033 | customer0033@example.com | BE | 6 Jun 2024, 16:58 UTC |
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
| … | |||
{
"country": {
"values": [
"BE",
"NL"
],
"inverse": false
}
}
<bs-datatable [virtualScroll]="true" [itemSize]="40" stateKey="customers"
[columns]="columns()" [fetch]="fetch" [distincts]="distincts"
[(filters)]="filters" [fetchParams]="params()" [rowKey]="rowKey">
<!-- The escape hatch, for the column with filterMode: 'custom'. Its
commands write the same filters model the built-in panels do. -->
<ng-container *bsDatatableFilter="let ctx = ctx">
<button type="button" (click)="ctx.apply(benelux, false)">Benelux</button>
<button type="button" (click)="ctx.clear()">Everywhere</button>
</ng-container>
</bs-datatable>
// Two-way: the user's filters land here, and a value set here filters the table.
filters = signal<DatatableFilters>({ country: { values: ['BE', 'NL'], inverse: false } });
// Your own query context, sent with every request. Must be JSON.
params = computed(() => ({ search: this.search() }));
columns = computed<DatatableColumnDef[]>(() => [
// A checkbox list of the column's distinct values, from [distincts].
{ name: 'name', filterable: true },
{ name: 'country', filterable: true, filterMode: 'custom' },
// Local time in the box, a UTC instant on the wire.
{ name: 'createdAt', filterable: true, filterMode: 'comparison',
filterInputType: 'datetime', filterOperators: ['gte', 'lt'] },
]);
// The server applies { page, perPage, sortColumns, filters, params }:
// filters: { country: { values: ['BE','NL'], inverse: false },
// CreatedAt: { values: [], inverse: false, operator: 'gte',
// operand: '2026-01-01T00:00:00.000Z' } }
fetch: DatatableFetch<Customer, Params> = ({ signal, ...req }) =>
firstValueFrom(this.http.post<DatatableFetchResponse<Customer>>('/api/customers/table', req));
// Value lists are faceted: computed over the OTHER columns' filters.
distincts = ({ signal, ...req }: DistinctsRequest<Params>) =>
firstValueFrom(this.http.post<DistinctValues>('/api/customers/distincts', req));
// stateKey: the server render fetches page 1 and hands it to the browser
// (TransferState), so the table hydrates with rows and fetches nothing.