A reusable, declarative Angular Material data table built with Angular 21. Configure columns with a JSON array, get sorting, multi-select, dropdown filters, pagination, sticky columns, row actions, theming, and dark mode out of the box — with an escape hatch for custom cell templates. Zero third-party dependencies beyond Angular Material.
| Live demo | ng-simple-table.vercel.app |
| npm | ngx-mat-simple-table |
| Source | github.com/xonaib/ng-simple-table |
Most Angular Material table examples require you to write <ng-container matColumnDef> blocks for every column. This component flips that: you describe your columns as data, and the table renders itself. You only drop down to a template when a cell needs custom markup.
Each column is identified by a key string: it is both the matColumnDef id Material uses internally and the property name read for default cell text (row[key]), so it stays aligned with your row type.
| Feature | Notes |
|---|---|
| Declarative columns | Pass a ColumnDef[] array — no per-column template boilerplate |
| Sorting | MatSort on data columns; sortable defaults to true — set sortable: false to opt out |
| Row selection | Multi-select checkboxes, master toggle, selectionChange output |
| Dropdown column filters | Distinct values built from dataSource; Apply/Clear emits (filterChange) |
| Pagination | MatPaginator with configurable page sizes; server- and client-side modes |
| Unknown-total pagination | Omit [length] in server-side mode — next/prev inferred automatically from page row count |
| Declarative actions | TableAction[] with five position slots: toolbar, above, below, row-inline, row-menu |
| Sticky columns | ColumnDef.sticky: 'left' | 'right' — pins columns to either edge; drag-reorder is disabled for sticky columns |
| Fill-container height | TableConfig.fillContainer: true — table expands to fill its parent; toolbar and paginator always stay in view |
| Column widths | Optional width on ColumnDef (number = px, string = CSS). Resize overrides at runtime |
| Column reorder | CDK drag-drop on header cells and in the column-chooser menu |
| Column chooser | Show/hide columns and reorder via toolbar menu with responsive max-height |
| Column resize | Drag handles on header cells; (columnWidthChange) emits Record<string, number> |
displayValue |
Per-column (value, row) => string transform for formatting or derived display text |
cellClass |
Per-column (value, row) => string | string[] for conditional cell styling (status badges, colour-coded priority, etc.) |
| Custom cell templates | cellDef attribute on <ng-template> — value must match the column key |
| State persistence | StStateStoringDirective saves column order, visibility, and widths to localStorage |
| Export to Excel | StExportDirective with full XLSX via ExcelJS; export all records via allDataProvider |
| Virtual scroll | TableConfig.virtual: true — renders only visible rows via CdkVirtualScrollViewport; works in client-side and server-side modes |
| CSS theming | 22 --st-* custom properties for colours, borders, font, row height, scrollbar, and sticky cells |
| Dark mode | Set body { color-scheme: dark } — all --st-* tokens and Angular Material tokens flip automatically |
| OnPush + signals | ChangeDetectionStrategy.OnPush throughout; zero ChangeDetectorRef usage |
npm install ngx-mat-simple-tableAdd provideHttpClient() to your app.config.ts if not already present.
import {
SimpleTableComponent,
CellDefDirective,
ColumnDef,
FilterType,
TableConfig,
} from 'ngx-mat-simple-table';
columns: ColumnDef[] = [
{ key: 'select' },
{ key: 'id', label: 'ID', width: 72, sticky: 'left' },
{ key: 'name', label: 'Name', width: 220, sticky: 'left' },
{
key: 'status',
label: 'Status',
hasColumnFilters: true,
filterType: FilterType.DropDown,
displayValue: v => String(v).toUpperCase(),
cellClass: v => `status-${v}`,
},
{ key: 'priority', label: 'Priority', hasColumnFilters: true, filterType: FilterType.DropDown },
{ key: 'dueDate', label: 'Due Date' },
];
tableConfig: TableConfig = {
isPaginated: true,
paginationOptions: { defaultPageSize: 25, pageSizeOptions: [10, 25, 50] },
horizontalScroll: true,
fillContainer: true,
};<simple-table
[dataSource]="pagedItems"
[tableColumns]="columns"
[tableConfig]="tableConfig"
[length]="totalCount"
[stickyHeaders]="true"
(page)="onPage($event)"
(sortChange)="onSort($event)"
(filterChange)="onFilter($event)"
(selectionChange)="onSelect($event)"
>
</simple-table>When a cell needs more than plain text — a link, a badge, a chip — add an <ng-template> with the cellDef attribute set to the same string as the column key.
<simple-table [dataSource]="items" [tableColumns]="columns" ...>
<ng-template cellDef="name" let-row>
<a [routerLink]="['/items', row.id]">{{ row.name }}</a>
</ng-template>
<ng-template cellDef="status" let-row>
<span [class]="'badge badge-' + row.status">{{ row.status }}</span>
</ng-template>
</simple-table>Tip:
cellClassonColumnDefhandles most styling-only cases without a template — reservecellDeffor when you need actual HTML structure.
| Property | Type | Description |
|---|---|---|
key |
string |
Required. Column id and default row[key] field. Use 'select' for the checkbox column. |
label |
string |
Header text. Title-cased from key if omitted. |
width |
number | string |
Optional width (number = px; string = any CSS length). Omitted = auto. |
sticky |
'left' | 'right' |
Pin column to an edge. Drag-reorder is disabled for sticky columns. |
sortable |
boolean |
Set false to disable sorting. Omitted = sortable. select is never sortable. |
hasColumnFilters |
boolean |
Shows the filter icon and builds a dropdown from distinct values. |
filterType |
FilterType |
FilterType.DropDown — checkbox list with search. |
displayValue |
(value, row) => string |
Transform the displayed cell text (formatting, derived values). |
cellClass |
(value, row) => string | string[] | null |
Return a CSS class or array of classes to apply to the body cell. |
Reserved key: st-layout-filler is used internally — do not use it as a column key.
| Property | Type | Default | Description |
|---|---|---|---|
isPaginated |
boolean |
false |
Show MatPaginator. |
paginationOptions |
PaginationOptions |
— | defaultPageSize and pageSizeOptions. |
clientSide |
boolean |
false |
Hand sorting, filtering, and pagination to an internal MatTableDataSource. |
horizontalScroll |
boolean |
false |
Enable horizontal scroll on the table wrapper. |
fillContainer |
boolean |
false |
Stretch the table to fill its parent height; toolbar and paginator stay in view. |
showColumnChooser |
boolean |
true |
Show the column-chooser button in the toolbar. |
showRefresh |
boolean |
true |
Show the refresh button in the toolbar. |
columnDraggable |
boolean |
true |
Enable column drag-reorder. |
columnResizable |
boolean |
true |
Enable column resize handles. |
maxHeight |
string |
— | CSS max-height on the scroll wrapper (ignored when fillContainer is true). |
virtual |
boolean |
false |
Enable virtual scrolling. Replaces the paginator with a CdkVirtualScrollViewport that only renders visible rows. Requires a defined height — use fillContainer: true or maxHeight. |
virtualRowHeight |
number |
48 |
Pixel height of each data row. Must match the actual rendered row height (same as --st-row-height). |
| Input | Type | Default | Description |
|---|---|---|---|
dataSource |
T[] | Observable<T[]> |
— | Row data. Required. |
tableColumns |
ColumnDef[] |
— | Column definitions. Required. |
tableConfig |
TableConfig |
{} |
Pagination, scroll, toolbar, drag/resize flags. |
tableId |
string |
— | Unique key for StStateStoringDirective persistence. |
length |
number |
0 |
Total row count for the paginator (server-side). |
pageIndex |
number |
— | Sync paginator when the host resets the page (server-side). |
selectedRows |
T[] |
— | Pre-select rows programmatically. |
stickyHeaders |
boolean |
false |
Sticky header row. |
virtualOffset |
number |
0 |
Server-side virtual scroll only. The absolute row index (0-based) of the first row in dataSource. See Virtual scroll — server-side. |
| Output | Payload | Description |
|---|---|---|
page |
PageEvent |
Page or page-size change. |
sortChange |
Sort |
Column sort changed. |
filterChange |
Map<string, ItemParent> |
Apply or Clear (map keys = column key). |
selectionChange |
T[] |
Selected rows. |
refresh |
void |
Refresh toolbar button clicked. |
columnOrderChange |
string[] |
Data column keys after header drag-reorder. |
columnWidthChange |
Record<string, number> |
Column widths in px after resize. |
virtualRangeChange |
VirtualRange |
Server-side virtual scroll only. Emits { start, end } as the user scrolls — the host should fetch that window and update [dataSource], [virtualOffset], and [length]. |
Override any --st-* property on the <simple-table> element or any ancestor:
simple-table {
--st-header-bg: #1e293b;
--st-header-color: #f1f5f9;
--st-border-color: #334155;
--st-row-hover-bg: #0f172a;
--st-row-selected-bg: #1e3a5f;
--st-sticky-cell-bg: #1a2535;
--st-cell-color: #e2e8f0;
--st-row-bg: transparent;
--st-font-size: 13px;
--st-row-height: 44px;
--st-toolbar-bg: #0f172a;
}| Token | Default | Controls |
|---|---|---|
--st-header-bg |
surface-variant |
Header row background |
--st-header-color |
on-surface-variant |
Header row text |
--st-border-color |
outline-variant |
All cell and toolbar borders |
--st-row-bg |
transparent |
Default body row background |
--st-row-hover-bg |
surface-container-low |
Hovered row background |
--st-row-selected-bg |
secondary-container |
Selected row background |
--st-sticky-cell-bg |
surface-container |
Body cells in sticky columns |
--st-cell-color |
on-surface |
Body cell text colour |
--st-font-size |
14px |
Body cell font size |
--st-row-height |
48px |
Row min-height |
--st-toolbar-bg |
surface |
Toolbar background |
--st-scrollbar-width |
thin |
Scrollbar width (thin | auto | none) |
--st-scrollbar-thumb |
outline |
Scrollbar thumb colour |
--st-scrollbar-track |
transparent |
Scrollbar track colour |
--st-chooser-max-height |
320px |
Column chooser list max-height before scroll |
--st-filter-popup-bg |
surface |
Filter dropdown background |
--st-filter-popup-border |
outline-variant |
Filter dropdown border |
--st-filter-header-bg |
surface-variant |
Filter dropdown header strip |
--st-filter-header-color |
on-surface-variant |
Filter dropdown header text |
--st-filter-footer-bg |
surface-variant |
Filter dropdown footer strip |
--st-filter-footer-border |
outline-variant |
Filter dropdown footer top border |
--st-filter-input-bg |
surface |
Filter search input background |
--st-filter-input-border |
outline |
Filter search input border |
All defaults alias Angular Material's --mat-sys-* tokens, so the full table adapts automatically when you switch color-scheme:
// toggle dark/light at runtime
document.body.style.colorScheme = 'dark'; // or 'light'Use the CSS light-dark() function for any custom cell colours so they adapt too:
.status-done {
background: light-dark(#dcfce7, #14532d);
color: light-dark(#15803d, #86efac);
}Set hasColumnFilters: true and filterType: FilterType.DropDown. The table builds a checkbox list per filterable column from distinct row[key] values in the current dataSource.
- Server-side: options reflect the current page only; stale selections are pruned on each fetch and
(filterChange)re-emits automatically. - Client-side: options span the full dataset.
Read ItemParent.selectedKeys in the (filterChange) handler for the active values.
Pass a TableAction<T>[] to [actions] to add buttons anywhere around the table without writing any extra template code.
import { TableAction } from 'ngx-mat-simple-table';
actions: TableAction<Task>[] = [
// left side of the toolbar
{
id: 'add',
label: 'New task',
icon: 'add',
position: 'toolbar',
color: 'primary',
variant: 'flat',
cb: () => this.openCreateDialog(),
},
{
id: 'bulk-delete',
label: 'Delete selected',
icon: 'delete_sweep',
position: 'toolbar',
color: 'warn',
variant: 'stroked',
disabled: () => this.selected().length === 0,
cb: () => this.bulkDelete(this.selected()),
},
// icon button on every row (label omitted → icon-only with tooltip)
{
id: 'edit',
icon: 'edit',
position: 'row-inline',
cb: (row) => this.openEditDialog(row),
},
// overflow menu on every row
{
id: 'delete',
label: 'Delete',
icon: 'delete',
position: 'row-menu',
color: 'warn',
cb: (row) => this.deleteTask(row),
},
// left side of the paginator row
{
id: 'export-selected',
label: 'Export selected',
icon: 'file_download',
position: 'below',
disabled: () => this.selected().length === 0,
cb: () => this.exportSelected(),
},
];<simple-table
[dataSource]="tasks"
[tableColumns]="columns"
[tableConfig]="config"
[actions]="actions"
(selectionChange)="selected.set($event)"
>
</simple-table>| Position | Where it renders |
|---|---|
toolbar |
Left side of the toolbar row, alongside the column-chooser and export icons |
above |
Same toolbar row, rendered before toolbar actions |
below |
Left side of the paginator row |
row-inline |
Icon button visible on every row in a sticky-end column |
row-menu |
Item inside the ⋯ overflow menu in the same sticky column |
| Property | Type | Description |
|---|---|---|
id |
string |
Required. Unique key; used as track id. |
position |
ActionPosition |
Required. Where the action renders. |
cb |
(row: T | undefined) => void |
Required. Called on click. row is the clicked row for row positions, undefined otherwise. |
label |
string |
Button label. Omit for an icon-only button (tooltip falls back to id). |
icon |
string |
Material icon name. |
variant |
'flat' | 'stroked' | 'text' | 'icon' |
Button style for non-row positions. Defaults to 'stroked'. |
color |
'primary' | 'accent' | 'warn' |
Material button colour. |
disabled |
(row: T | undefined) => boolean |
Return true to disable. |
If your API doesn't return a total row count, omit [length]. The table infers next/prev availability from the number of rows returned: a full page enables the next button; a short page disables it. No extra configuration needed.
<simple-table
[dataSource]="currentPage()"
[pageIndex]="_pageIndex()"
[tableColumns]="columns"
[tableConfig]="config"
(page)="onPage($event)"
>
</simple-table>Set virtual: true in TableConfig to replace pagination with CdkVirtualScrollViewport. Only the rows visible in the viewport (plus a small buffer) are in the DOM, keeping rendering fast for large datasets.
Requirements:
- The table must have a defined height. Use
fillContainer: true(recommended) ormaxHeight. virtualRowHeightmust equal the actual rendered row height in pixels (default48, matching--st-row-height).
Pass the entire dataset as dataSource. The table filters, sorts, and virtualises it internally — no additional wiring needed.
tableConfig: TableConfig = {
virtual: true,
virtualRowHeight: 48,
clientSide: true,
fillContainer: true,
};<simple-table
[dataSource]="allRows"
[tableColumns]="columns"
[tableConfig]="tableConfig"
[stickyHeaders]="true"
>
</simple-table>The table emits (virtualRangeChange) as the user scrolls. The host fetches the requested window from the server and passes it back with the window's absolute start index ([virtualOffset]) and the total row count ([length]).
tableConfig: TableConfig = {
virtual: true,
virtualRowHeight: 48,
fillContainer: true,
};
// --- state in the host component ---
readonly rows = signal<Row[]>([]);
readonly totalCount = signal(0);
readonly windowOffset = signal(0);
onVirtualRangeChange(range: VirtualRange): void {
// range.start / range.end are absolute indices into the full dataset.
// Fetch the window from your API and update the signals.
this.http.get<{ data: Row[]; total: number }>('/api/rows', {
params: { offset: range.start, limit: range.end - range.start },
}).subscribe(({ data, total }) => {
this.rows.set(data);
this.totalCount.set(total);
this.windowOffset.set(range.start);
});
}<simple-table
[dataSource]="rows()"
[tableColumns]="columns"
[tableConfig]="tableConfig"
[length]="totalCount()"
[virtualOffset]="windowOffset()"
[stickyHeaders]="true"
(virtualRangeChange)="onVirtualRangeChange($event)"
>
</simple-table>How the table positions the window:
Each row N in the full dataset sits at pixel N × virtualRowHeight from the top of the scroll container. When the host provides rows [offset … offset+n], the table sets margin-top: offset × virtualRowHeight on the content wrapper, placing those rows at exactly the right scroll position. length × virtualRowHeight drives the scrollbar height so the full range is scrollable immediately.
Add <st-state-storing /> inside <simple-table> and provide a unique tableId:
<simple-table tableId="my-table" ...>
<st-state-storing />
</simple-table>Column order, visibility, and widths are saved to localStorage under that key and restored on next load. New columns added after a saved state are appended at the end rather than dropped.
Add <st-export /> inside <simple-table>. For server-side tables, pass [allDataProvider] to fetch all records for export regardless of the current page:
<simple-table ...>
<st-export [allDataProvider]="getAllForExport" />
</simple-table>readonly getAllForExport = (): Promise<Row[]> =>
firstValueFrom(this.http.get<{ data: Row[] }>('/api/rows').pipe(map(r => r.data)));- Node.js 20+
- npm 10+
git clone https://github.com/xonaib/ng-simple-table.git
cd ng-simple-table
npm install
npm run build:lib # build the library once before starting the demo
npm start # opens http://localhost:4200The demo runs against an in-memory interceptor — no backend required.
# In one terminal — rebuild the library on every change
npm run build:lib:watch
# In another terminal — serve the demo app
npm startChanges to projects/ngx-mat-simple-table/ are picked up automatically; refresh the browser to see them.
npm run build # builds lib + demo appnpm run publish:lib # runs build:lib then publishes dist/ngx-mat-simple-table to npmv1.3
TableConfig.scrollbarVisibility: 'auto' | 'always' | 'hover'- Inline row editing
- Storybook stories
- Unit test suite
MIT
I got tired of copy-pasting the same Angular Material table setup, so I built a library — covers the build process, decisions, and what broke along the way.
- Declarative actions API —
TableAction<T>[]with five

