Skip to main content

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.

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

Table styles
Name Role Status
Ava Nguyen Engineer Active
Ben Okafor Product manager Active
Chloe Rossi Support lead Inactive
Dan Smith Designer Active
Primary theme
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.

Employees, sortable
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.

Employees, searchable and paged
Email
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.

Selectable employees
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.

Employees with custom cells
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.

Employees with details
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
  • Email
  • Role
  • Department
  • Started
Employees with configurable columns
Name Email 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>

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.

Employees with a sticky header
# 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.

Employees, loading and empty states
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.

Employees loaded page by page
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.

Employees, stacked in narrow containers
Name Email 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

NameTypeDefaultDescription
datareadonly T[]requiredRows of the table.
columnsBsTableColumn<T>[]requiredColumn definitions. Supports [(columns)]; resizing and reordering write back.
rowKey(row: T) => unknownrow => rowReturns a unique key for a row, used to track rows, selection and expansion.
sortBsTableSort | nullnullSorted column and direction. Supports [(sort)].
filterstring''Text filter matched against the text of every filterable, visible column.
filterFn((row: T, filter: string) => boolean) | nullnullCustom filter, called with each row and the trimmed filter text.
pagenumber1Current page, starting at 1. Supports [(page)].
pageSizenumber0Rows per page. 0 shows every row.
manualbooleanfalseLeaves sorting, filtering and paging to you, such as on a server.
totalnumber | nullnullNumber of rows across all pages, in manual mode.
selection'none' | 'single' | 'multiple''none'How rows can be selected.
selectedT[][]Selected rows. Supports [(selected)].
expandedT[][]Expanded rows. Supports [(expanded)].
resizablebooleanfalseShows resize handles on every column.
reorderablebooleanfalseLets users drag column headers to reorder them.
stickyHeaderbooleanfalseKeeps the header in view while the table scrolls in its container.
loadingbooleanfalseShows a loading row and sets aria-busy.
stripedbooleanfalseZebra-stripes the rows.
hoverbooleanfalseHighlights the row under the pointer.
borderedbooleanfalseAdds borders on every side of the table and cells.
borderlessbooleanfalseRemoves all borders.
size'sm' | 'md''md'Cell density.
themeBsTheme | nullnullTheme color of the table.
stackedBsTableStacked | nullnullStacks rows into blocks below a container breakpoint ('sm' to '2xl'), or 'always'.
emptyTextstring'No results'Text shown when there are no rows.
loadingTextstring'Loading…'Text of the loading row.
selectAllLabelstring'Select all rows'Accessible label of the select-all checkbox.
selectHeaderstring'Select'Accessible name of the selection column header.
rowLabel(row: T) => stringfirst column textAccessible label of each row's checkbox or radio.
detailHeaderstring'Details'Accessible name of the expand column header.
expandLabelstring'Show details'Accessible label of each row's expand button.
resizeLabelstring'Resize'Start of each resize handle's label, followed by the column header.

Outputs

NameTypeDescription
columnsChangeBsTableColumn<T>[]Emits columns with new widths or order.
sortChangeBsTableSort | nullEmits the new sort.
pageChangenumberEmits the new page.
selectedChangeT[]Emits the selected rows.
expandedChangeT[]Emits the expanded rows.

Methods

NameTypeDescription
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()numberNumber of rows across all pages.
pageCount()numberNumber of pages, at least 1.
currentPage()numberCurrent page, clamped to the available pages.
select(row) / deselect(row) / toggleSelected(row)voidChanges the selection.
toggleAll()voidSelects every row on the page, or unselects them when they all are.
toggleExpanded(row)voidShows or hides a row's detail.
toggleSort(column)voidCycles a column through ascending, descending and unsorted.
moveColumn(key, index)voidMoves a column to a new position.
setWidth(key, width)voidSets a column width in pixels.

BsTableColumn

Selector: interface (columns input)

Inputs

NameTypeDefaultDescription
keystringUnique key, and the row property shown by default.
headerstringHeader text.
value(row: T) => unknownReads the cell value from a row.
format(value: unknown, row: T) => stringFormats the value as text.
sortablebooleanShows a sort button in the header.
compare(a: T, b: T) => numberCompares two rows for sorting, ascending.
filterablebooleanIncludes the column in the text filter. Defaults to true.
hiddenbooleanHides the column.
width / minWidth / maxWidthnumberWidth and resize limits in pixels (defaults 48 and 1000).
resizablebooleanShows a resize handle, overriding the table's resizable.
align'start' | 'center' | 'end'Text alignment of the header and cells.
headerClass / cellClassstringClasses added to the header cell and each cell.

BsTableCell

Selector: ng-template[bsTableCell]

Inputs

NameTypeDefaultDescription
bsTableCellstringrequiredKey of the column.
bsTableCellOfreadonly T[][]The rows, only to type the template context.

BsTableDetail

Selector: ng-template[bsTableDetail]

Inputs

NameTypeDefaultDescription
bsTableDetailOfreadonly T[][]The rows, only to type the template context.

BsTableEmpty

Selector: ng-template[bsTableEmpty]