Table
Data tables on Bootstrap’s table styles, with sorting, search, pagination, row selection, expandable rows and resizable, reorderable columns.
Bootstrap’s table docs describe the markup and CSS.
Example
Give table[bsTable] its rows with data and column definitions with [(columns)]. Each column shows row[key] unless it has a value function, and format turns the value into text. Add a <caption> so screen reader users know what the table holds.
| Name | Role | Department | Started |
|---|---|---|---|
| Ava Nguyen | Engineer | Engineering | 2015-01-01 |
| Ben Okafor | Product manager | Finance | 2016-02-02 |
| Chloe Rossi | Support lead | Design | 2017-03-03 |
| Dan Smith | Designer | Support | 2018-04-04 |
| Ella Tanaka | Analyst | Product | 2019-05-05 |
<table bsTable [data]="employees" [(columns)]="columns">
<caption>
Employees
</caption>
</table>Variants
Bootstrap’s table styles are inputs: striped, hover, bordered, borderless, size="sm" for a denser table, and theme for a colored one. You can also add other Bootstrap classes, such as align-middle, to the <table> itself.
| Name | Role | Status |
|---|---|---|
| Ava Nguyen | Engineer | Active |
| Ben Okafor | Product manager | Active |
| Chloe Rossi | Support lead | Inactive |
| Dan Smith | Designer | Active |
| Name | Role | Status |
|---|---|---|
| Ava Nguyen | Engineer | Active |
| Ben Okafor | Product manager | Active |
| Chloe Rossi | Support lead | Inactive |
| Dan Smith | Designer | Active |
<div class="d-flex flex-wrap gap-5 mb-5">
<div bsFormField>
<input #s type="checkbox" bsCheck [checked]="striped()" (change)="striped.set(s.checked)" />
<label bsLabel>Striped</label>
</div>
<div bsFormField>
<input #h type="checkbox" bsCheck [checked]="hover()" (change)="hover.set(h.checked)" />
<label bsLabel>Hover</label>
</div>
<div bsFormField>
<input #b type="checkbox" bsCheck [checked]="bordered()" (change)="bordered.set(b.checked)" />
<label bsLabel>Bordered</label>
</div>
<div bsFormField>
<input
#bl
type="checkbox"
bsCheck
[checked]="borderless()"
(change)="borderless.set(bl.checked)" />
<label bsLabel>Borderless</label>
</div>
<div bsFormField>
<input #sm type="checkbox" bsCheck [checked]="small()" (change)="small.set(sm.checked)" />
<label bsLabel>Small</label>
</div>
</div>
<table
bsTable
[data]="employees"
[(columns)]="columns"
[striped]="striped()"
[hover]="hover()"
[bordered]="bordered()"
[borderless]="borderless()"
[size]="small() ? 'sm' : 'md'">
<caption>
Table styles
</caption>
</table>
<table bsTable [data]="employees" [(columns)]="columns" theme="primary" class="mb-0">
<caption>
Primary theme
</caption>
</table>Sorting
Mark columns sortable to put a sort button in their header. Each click cycles through ascending, descending and unsorted, and the header gets aria-sort. Bind the state with [(sort)]. Numbers, dates and text (with natural number order) sort as you’d expect and empty values go last; set a column’s compare for anything else.
| Ava Nguyen | Engineering | $52,000 | 2015-01-01 |
| Ben Okafor | Finance | $89,000 | 2016-02-02 |
| Chloe Rossi | Design | $126,000 | 2017-03-03 |
| Dan Smith | Support | $73,000 | 2018-04-04 |
| Ella Tanaka | Product | $110,000 | 2019-05-05 |
| Finn Kowalski | Engineering | $57,000 | 2020-06-06 |
Sorted by name, ascending
<table bsTable [data]="employees" [(columns)]="columns" [(sort)]="sort" hover>
<caption>
Employees, sortable
</caption>
</table>
<p class="mb-0">
@if (sort(); as sort) {
Sorted by {{ sort.key }}, {{ sort.direction === 'asc' ? 'ascending' : 'descending' }}
} @else {
Not sorted
}
</p>Search and pagination
Bind your own search field to filter: rows match when the text of any visible column contains it, ignoring case. Set filterFn for custom matching, or filterable: false on a column to skip it. Set pageSize and bind [(page)], then wire a bsPagination to the table’s pageCount() and currentPage(), which stays in range when the filter leaves fewer pages.
| Ava Nguyen | [email protected] | Engineer | Engineering |
| Ben Okafor | [email protected] | Product manager | Finance |
| Chloe Rossi | [email protected] | Support lead | Design |
| Dan Smith | [email protected] | Designer | Support |
| Ella Tanaka | [email protected] | Analyst | Product |
| Finn Kowalski | [email protected] | Engineer | Engineering |
| Grace Garcia | [email protected] | Product manager | Finance |
| Hugo Meyer | [email protected] | Support lead | Design |
48 of 48 employees
<div class="d-flex flex-wrap align-items-end gap-5 mb-5">
<div bsFormField class="flex-grow-1">
<label bsFormLabel>Search employees</label>
<input #q type="search" bsFormControl [value]="search()" (input)="onSearch(q.value)" />
</div>
<div bsFormField>
<label bsFormLabel>Rows per page</label>
<select #size bsFormControl (change)="pageSize.set(+size.value); page.set(1)">
<option value="5">5</option>
<option value="8" selected>8</option>
<option value="20">20</option>
<option value="0">All</option>
</select>
</div>
</div>
<table
bsTable
#table="bsTable"
[data]="employees"
[(columns)]="columns"
[(sort)]="sort"
[(page)]="page"
[pageSize]="pageSize()"
[filter]="search()"
striped
hover>
<caption>
Employees, searchable and paged
</caption>
</table>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-5">
<p class="mb-0" aria-live="polite">{{ table.totalRows() }} of {{ employees.length }} employees</p>
<nav
bsPagination
aria-label="Employee pages"
[page]="table.currentPage()"
(pageChange)="page.set($event)"
[pageCount]="table.pageCount()"></nav>
</div>Row selection
Set selection to multiple for a checkbox column with a select-all checkbox in the header (it selects the rows on the current page), or to single for radios. Selected rows get .table-active. Bind [(selected)], and set rowKey when rows are recreated, for example after a reload, so selection survives. Each checkbox is labelled with the row’s first column; change that with rowLabel.
| Name | Role | Department | |
|---|---|---|---|
| Ava Nguyen | Engineer | Engineering | |
| Ben Okafor | Product manager | Finance | |
| Chloe Rossi | Support lead | Design | |
| Dan Smith | Designer | Support | |
| Ella Tanaka | Analyst | Product |
Selected: Ben Okafor
<div class="d-flex gap-5 mb-5" role="radiogroup" aria-label="Selection mode">
<div bsFormField>
<input
type="radio"
bsRadio
name="selection-mode"
[checked]="mode() === 'multiple'"
(change)="setMode('multiple')" />
<label bsLabel>Multiple</label>
</div>
<div bsFormField>
<input
type="radio"
bsRadio
name="selection-mode"
[checked]="mode() === 'single'"
(change)="setMode('single')" />
<label bsLabel>Single</label>
</div>
</div>
<table
bsTable
[data]="employees"
[(columns)]="columns"
[selection]="mode()"
[(selected)]="selected"
[rowKey]="rowKey"
hover>
<caption>
Selectable employees
</caption>
</table>
<p class="mb-0" aria-live="polite">Selected: {{ names() || 'nobody' }}</p>Custom cells
Put an ng-template bsTableCell="key" inside the table to render a column’s cells yourself, such as badges, links or action buttons. The template gets the row, plus value, column and index. Add [bsTableCellOf]="rows" so strict templates know the row’s type.
| Name | Status | Salary | Actions |
|---|---|---|---|
Ava Nguyen | Active | $52,000 | |
Ben Okafor | Active | $89,000 | |
Chloe Rossi | Inactive | $126,000 | |
Dan Smith | Active | $73,000 | |
Ella Tanaka | On leave | $110,000 |
<table bsTable [data]="employees" [(columns)]="columns" class="align-middle">
<caption>
Employees with custom cells
</caption>
<ng-template bsTableCell="name" [bsTableCellOf]="employees" let-row>
<div class="fw-semibold">{{ row.name }}</div>
<div class="fs-sm fg-3">{{ row.email }}</div>
</ng-template>
<ng-template bsTableCell="status" [bsTableCellOf]="employees" let-row>
<span bsBadge variant="subtle" [theme]="theme(row.status)">{{ row.status }}</span>
</ng-template>
<ng-template bsTableCell="actions" [bsTableCellOf]="employees" let-row>
<button
type="button"
bsButton="outline"
theme="primary"
size="sm"
(click)="message.set(`Editing ${row.name}`)">
Edit <span class="visually-hidden">{{ row.name }}</span>
</button>
</ng-template>
</table>
<p class="mb-0" aria-live="polite">{{ message() }}</p>Expandable rows
Add an ng-template bsTableDetail to give each row a toggle button that shows the template in a full-width row underneath. The button has aria-expanded, and aria-controls while the row is open. Bind [(expanded)] to open rows from code.
| Details | Name | Role | Department |
|---|---|---|---|
| Ava Nguyen | Engineer | Engineering | |
| Ben Okafor | Product manager | Finance | |
| Chloe Rossi | Support lead | Design | |
| Dan Smith | Designer | Support |
<table bsTable [data]="employees" [(columns)]="columns" [(expanded)]="expanded" class="mb-0">
<caption>
Employees with details
</caption>
<ng-template bsTableDetail [bsTableDetailOf]="employees" let-row>
<dl class="row mb-0">
<dt class="col-sm-3">Email</dt>
<dd class="col-sm-9">{{ row.email }}</dd>
<dt class="col-sm-3">Started</dt>
<dd class="col-sm-9 mb-0">{{ row.started }}</dd>
</dl>
</ng-template>
</table>Column visibility, order and width
Columns are plain data, so hide one by setting hidden. With reorderable, headers can be dragged onto each other, and moveColumn(key, index) gives keyboard and touch users the same control, as the arrow buttons here do. With resizable (or a column’s own resizable), each header gets a handle that you drag, or focus and move with the arrow keys (Shift for bigger steps). Both write the new order and widths back through [(columns)], so you can save them.
- Name
- Role
- Department
- Started
| Name | Role | Started | |
|---|---|---|---|
| Ava Nguyen | [email protected] | Engineer | 2015-01-01 |
| Ben Okafor | [email protected] | Product manager | 2016-02-02 |
| Chloe Rossi | [email protected] | Support lead | 2017-03-03 |
| Dan Smith | [email protected] | Designer | 2018-04-04 |
| Ella Tanaka | [email protected] | Analyst | 2019-05-05 |
<ul class="list-unstyled d-flex flex-wrap gap-4 mb-5" aria-label="Columns">
@for (column of columns(); track column.key) {
<li class="d-flex align-items-center gap-2 border rounded px-3 py-1">
<input
#visible
type="checkbox"
bsCheck
[checked]="!column.hidden"
[attr.aria-label]="`Show ${column.header}`"
(change)="toggle(column.key, visible.checked)" />
<span>{{ column.header }}</span>
<button
type="button"
bsButton="text"
size="xs"
[disabled]="$first"
[attr.aria-label]="`Move ${column.header} left`"
(click)="table.moveColumn(column.key, $index - 1)">
←
</button>
<button
type="button"
bsButton="text"
size="xs"
[disabled]="$last"
[attr.aria-label]="`Move ${column.header} right`"
(click)="table.moveColumn(column.key, $index + 1)">
→
</button>
</li>
}
</ul>
<div class="table-responsive">
<table
bsTable
#table="bsTable"
[data]="employees"
[(columns)]="columns"
resizable
reorderable
bordered
class="mb-0">
<caption>
Employees with configurable columns
</caption>
</table>
</div>Sticky header
Set stickyHeader to keep the header in view while the table scrolls inside a container with a height. Make the scroll container focusable and give it a name so keyboard users can scroll it.
| # | Name | Role | Department |
|---|---|---|---|
| 1 | Ava Nguyen | Engineer | Engineering |
| 2 | Ben Okafor | Product manager | Finance |
| 3 | Chloe Rossi | Support lead | Design |
| 4 | Dan Smith | Designer | Support |
| 5 | Ella Tanaka | Analyst | Product |
| 6 | Finn Kowalski | Engineer | Engineering |
| 7 | Grace Garcia | Product manager | Finance |
| 8 | Hugo Meyer | Support lead | Design |
| 9 | Isla Nguyen | Designer | Support |
| 10 | Jack Okafor | Analyst | Product |
| 11 | Ava Rossi | Engineer | Engineering |
| 12 | Ben Smith | Product manager | Finance |
| 13 | Chloe Tanaka | Support lead | Design |
| 14 | Dan Kowalski | Designer | Support |
| 15 | Ella Garcia | Analyst | Product |
| 16 | Finn Meyer | Engineer | Engineering |
| 17 | Grace Nguyen | Product manager | Finance |
| 18 | Hugo Okafor | Support lead | Design |
| 19 | Isla Rossi | Designer | Support |
| 20 | Jack Smith | Analyst | Product |
<div
class="overflow-auto border rounded"
style="max-height: 18rem"
tabindex="0"
role="region"
aria-label="Employees, scrollable">
<table
bsTable
[data]="employees"
[(columns)]="columns"
stickyHeader
striped
size="sm"
class="mb-0">
<caption class="visually-hidden">
Employees with a sticky header
</caption>
</table>
</div>Loading and empty states
While loading is set, the table shows a spinner row and aria-busy="true". When no rows are left, it shows emptyText, or your own ng-template bsTableEmpty.
| Name | Role | Status |
|---|---|---|
| Loading… | ||
<div class="d-flex gap-5 mb-5">
<div bsFormField>
<input #l type="checkbox" bsCheck [checked]="loading()" (change)="loading.set(l.checked)" />
<label bsLabel>Loading</label>
</div>
<div bsFormField>
<input #e type="checkbox" bsCheck [checked]="empty()" (change)="empty.set(e.checked)" />
<label bsLabel>No data</label>
</div>
</div>
<table
bsTable
[data]="empty() ? [] : employees"
[(columns)]="columns"
[loading]="loading()"
class="mb-0">
<caption>
Employees, loading and empty states
</caption>
<ng-template bsTableEmpty>
<p class="mb-2">No employees yet.</p>
<button type="button" bsButton theme="primary" size="sm" (click)="empty.set(false)">
Load sample data
</button>
</ng-template>
</table>Server-side data
Set manual when a server sorts, filters and pages: the table shows data as it is, still updates sort and page when users click, and uses total for pageCount(). Here a resource refetches when either changes.
| Ava Nguyen | Engineer | Engineering |
| Ben Okafor | Product manager | Finance |
| Chloe Rossi | Support lead | Design |
| Dan Smith | Designer | Support |
| Ella Tanaka | Analyst | Product |
<table
bsTable
#table="bsTable"
manual
[data]="rows()"
[total]="total()"
[pageSize]="pageSize"
[loading]="result.isLoading()"
[(columns)]="columns"
[(sort)]="sort"
[(page)]="page">
<caption>
Employees loaded page by page
</caption>
</table>
<nav
bsPagination
aria-label="Server-side employee pages"
[(page)]="page"
[pageCount]="table.pageCount()"></nav>Stacked on small screens
Set stacked to a breakpoint to show each row as a block, with column names as labels, when the surrounding .table-responsive container is narrower than it. Set it to always to always stack.
| Name | Role | Department | |
|---|---|---|---|
| Ava Nguyen | [email protected] | Engineer | Engineering |
| Ben Okafor | [email protected] | Product manager | Finance |
| Chloe Rossi | [email protected] | Support lead | Design |
<div class="table-responsive">
<table bsTable [data]="employees" [(columns)]="columns" stacked="md" class="mb-0">
<caption>
Employees, stacked in narrow containers
</caption>
</table>
</div>API
BsTable
Selector: table[bsTable] · Exported as: bsTable
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
data | readonly T[] | required | Rows of the table. |
columns | BsTableColumn<T>[] | required | Column definitions. Supports [(columns)]; resizing and reordering write back. |
rowKey | (row: T) => unknown | row => row | Returns a unique key for a row, used to track rows, selection and expansion. |
sort | BsTableSort | null | null | Sorted column and direction. Supports [(sort)]. |
filter | string | '' | Text filter matched against the text of every filterable, visible column. |
filterFn | ((row: T, filter: string) => boolean) | null | null | Custom filter, called with each row and the trimmed filter text. |
page | number | 1 | Current page, starting at 1. Supports [(page)]. |
pageSize | number | 0 | Rows per page. 0 shows every row. |
manual | boolean | false | Leaves sorting, filtering and paging to you, such as on a server. |
total | number | null | null | Number of rows across all pages, in manual mode. |
selection | 'none' | 'single' | 'multiple' | 'none' | How rows can be selected. |
selected | T[] | [] | Selected rows. Supports [(selected)]. |
expanded | T[] | [] | Expanded rows. Supports [(expanded)]. |
resizable | boolean | false | Shows resize handles on every column. |
reorderable | boolean | false | Lets users drag column headers to reorder them. |
stickyHeader | boolean | false | Keeps the header in view while the table scrolls in its container. |
loading | boolean | false | Shows a loading row and sets aria-busy. |
striped | boolean | false | Zebra-stripes the rows. |
hover | boolean | false | Highlights the row under the pointer. |
bordered | boolean | false | Adds borders on every side of the table and cells. |
borderless | boolean | false | Removes all borders. |
size | 'sm' | 'md' | 'md' | Cell density. |
theme | BsTheme | null | null | Theme color of the table. |
stacked | BsTableStacked | null | null | Stacks rows into blocks below a container breakpoint ('sm' to '2xl'), or 'always'. |
emptyText | string | 'No results' | Text shown when there are no rows. |
loadingText | string | 'Loading…' | Text of the loading row. |
selectAllLabel | string | 'Select all rows' | Accessible label of the select-all checkbox. |
selectHeader | string | 'Select' | Accessible name of the selection column header. |
rowLabel | (row: T) => string | first column text | Accessible label of each row's checkbox or radio. |
detailHeader | string | 'Details' | Accessible name of the expand column header. |
expandLabel | string | 'Show details' | Accessible label of each row's expand button. |
resizeLabel | string | 'Resize' | Start of each resize handle's label, followed by the column header. |
Outputs
| Name | Type | Description |
|---|---|---|
columnsChange | BsTableColumn<T>[] | Emits columns with new widths or order. |
sortChange | BsTableSort | null | Emits the new sort. |
pageChange | number | Emits the new page. |
selectedChange | T[] | Emits the selected rows. |
expandedChange | T[] | Emits the expanded rows. |
Methods
| Name | Type | Description |
|---|---|---|
rows() | readonly T[] | Rows on the current page, after filtering and sorting. |
filteredRows() | readonly T[] | Rows matching the filter. |
visibleColumns() | BsTableColumn<T>[] | Columns that are not hidden. |
totalRows() | number | Number of rows across all pages. |
pageCount() | number | Number of pages, at least 1. |
currentPage() | number | Current page, clamped to the available pages. |
select(row) / deselect(row) / toggleSelected(row) | void | Changes the selection. |
toggleAll() | void | Selects every row on the page, or unselects them when they all are. |
toggleExpanded(row) | void | Shows or hides a row's detail. |
toggleSort(column) | void | Cycles a column through ascending, descending and unsorted. |
moveColumn(key, index) | void | Moves a column to a new position. |
setWidth(key, width) | void | Sets a column width in pixels. |
BsTableColumn
Selector: interface (columns input)
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
key | string | Unique key, and the row property shown by default. | |
header | string | Header text. | |
value | (row: T) => unknown | Reads the cell value from a row. | |
format | (value: unknown, row: T) => string | Formats the value as text. | |
sortable | boolean | Shows a sort button in the header. | |
compare | (a: T, b: T) => number | Compares two rows for sorting, ascending. | |
filterable | boolean | Includes the column in the text filter. Defaults to true. | |
hidden | boolean | Hides the column. | |
width / minWidth / maxWidth | number | Width and resize limits in pixels (defaults 48 and 1000). | |
resizable | boolean | Shows a resize handle, overriding the table's resizable. | |
align | 'start' | 'center' | 'end' | Text alignment of the header and cells. | |
headerClass / cellClass | string | Classes added to the header cell and each cell. |
BsTableCell
Selector: ng-template[bsTableCell]
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
bsTableCell | string | required | Key of the column. |
bsTableCellOf | readonly T[] | [] | The rows, only to type the template context. |
BsTableDetail
Selector: ng-template[bsTableDetail]
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
bsTableDetailOf | readonly T[] | [] | The rows, only to type the template context. |
BsTableEmpty
Selector: ng-template[bsTableEmpty]