Datatable

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.

Styling — this component renders in the light DOM

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.

Basic usage

  
    
      
        <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);
        
    
      
        }
        
    

Sortable columns

  
    
      
        <!-- 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>
        
    

Column resizing

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.

  • Tab reaches each column's resize handle.
  • ← / → make the column 10 px narrower or wider.
  • Enter opens the resize options: narrower, wider, fit to content, reset. Esc closes them.
  
    
      
        <!-- 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">&#8943;</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>
        
    

Fit a sized parent

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.

  
    
      
        <!-- 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.

  
    
      
        <!-- 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>
        
    

Row selection

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").

Selected 0 artist(s)·Opened: —

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;
        
    

Virtual scrolling — lazy windowed fetch

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)

  
    
      
        <!-- 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 }
        
    

Tree mode — expandable rows

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.

  
    
      
        <!-- 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 };
        
    
      
          };
        
    
      
        }
        
    

Column filters

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.

Keyboard, rows — default (multiple) mode:
  • ↑ / ↓ move between rows.
  • Enter or Space selects the row, then opens it (rowClick) — exactly like a click.
  • Ctrl+Space toggles the row and Shift+Space selects a range, the same modifiers as Ctrl+click and Shift+click.
  • → / ← expand / collapse a tree row.
  • Tab reaches each row checkbox; Space on it toggles that row.
Keyboard, rows — checkbox mode:
  • ↑ / ↓ move between rows.
  • Enter opens the row (rowClick) and never selects it.
  • Space toggles the row's checkbox and does not open it. There are no ranges.
  • → / ← expand / collapse a tree row.
  • A click anywhere in the checkbox cell toggles the row; a click elsewhere only opens 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.

86 customer(s) match. The model: { "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.