simple-table

May 17, 2026 · View on GitHub

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 demong-simple-table.vercel.app
npmngx-mat-simple-table
Sourcegithub.com/xonaib/ng-simple-table

Screenshots

simple-table — sorting, filters, column chooser, export, virtual scroll, and server/client-side toggle

Rich Cells Demo — custom cellDef templates with avatars, status badges, tags, and inline action buttons

Why

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.

Features

FeatureNotes
Declarative columnsPass a ColumnDef[] array — no per-column template boilerplate
SortingMatSort on data columns; sortable defaults to true — set sortable: false to opt out
Row selectionMulti-select checkboxes, master toggle, selectionChange output
Dropdown column filtersDistinct values built from dataSource; Apply/Clear emits (filterChange)
PaginationMatPaginator with configurable page sizes; server- and client-side modes
Unknown-total paginationOmit [length] in server-side mode — next/prev inferred automatically from page row count
Declarative actionsTableAction[] with five position slots: toolbar, above, below, row-inline, row-menu
Sticky columnsColumnDef.sticky: 'left' | 'right' — pins columns to either edge; drag-reorder is disabled for sticky columns
Fill-container heightTableConfig.fillContainer: true — table expands to fill its parent; toolbar and paginator always stay in view
Column widthsOptional width on ColumnDef (number = px, string = CSS). Resize overrides at runtime
Column reorderCDK drag-drop on header cells and in the column-chooser menu
Column chooserShow/hide columns and reorder via toolbar menu with responsive max-height
Column resizeDrag handles on header cells; (columnWidthChange) emits Record<string, number>
displayValuePer-column (value, row) => string transform for formatting or derived display text
cellClassPer-column (value, row) => string | string[] for conditional cell styling (status badges, colour-coded priority, etc.)
Custom cell templatescellDef attribute on <ng-template> — value must match the column key
State persistenceStStateStoringDirective saves column order, visibility, and widths to localStorage
Export to ExcelStExportDirective with full XLSX via ExcelJS; export all records via allDataProvider
Virtual scrollTableConfig.virtual: true — renders only visible rows via CdkVirtualScrollViewport; works in client-side and server-side modes
CSS theming22 --st-* custom properties for colours, borders, font, row height, scrollbar, and sticky cells
Dark modeSet body { color-scheme: dark } — all --st-* tokens and Angular Material tokens flip automatically
OnPush + signalsChangeDetectionStrategy.OnPush throughout; zero ChangeDetectorRef usage

Setup

npm install ngx-mat-simple-table

Add provideHttpClient() to your app.config.ts if not already present.

Quick start

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>

Custom cell templates

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: cellClass on ColumnDef handles most styling-only cases without a template — reserve cellDef for when you need actual HTML structure.

Column definition (ColumnDef)

PropertyTypeDescription
keystringRequired. Column id and default row[key] field. Use 'select' for the checkbox column.
labelstringHeader text. Title-cased from key if omitted.
widthnumber | stringOptional width (number = px; string = any CSS length). Omitted = auto.
sticky'left' | 'right'Pin column to an edge. Drag-reorder is disabled for sticky columns.
sortablebooleanSet false to disable sorting. Omitted = sortable. select is never sortable.
hasColumnFiltersbooleanShows the filter icon and builds a dropdown from distinct values.
filterTypeFilterTypeFilterType.DropDown — checkbox list with search.
displayValue(value, row) => stringTransform the displayed cell text (formatting, derived values).
cellClass(value, row) => string | string[] | nullReturn 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.

Table config (TableConfig)

PropertyTypeDefaultDescription
isPaginatedbooleanfalseShow MatPaginator.
paginationOptionsPaginationOptions—defaultPageSize and pageSizeOptions.
clientSidebooleanfalseHand sorting, filtering, and pagination to an internal MatTableDataSource.
horizontalScrollbooleanfalseEnable horizontal scroll on the table wrapper.
fillContainerbooleanfalseStretch the table to fill its parent height; toolbar and paginator stay in view.
showColumnChooserbooleantrueShow the column-chooser button in the toolbar.
showRefreshbooleantrueShow the refresh button in the toolbar.
columnDraggablebooleantrueEnable column drag-reorder.
columnResizablebooleantrueEnable column resize handles.
maxHeightstring—CSS max-height on the scroll wrapper (ignored when fillContainer is true).
virtualbooleanfalseEnable virtual scrolling. Replaces the paginator with a CdkVirtualScrollViewport that only renders visible rows. Requires a defined height — use fillContainer: true or maxHeight.
virtualRowHeightnumber48Pixel height of each data row. Must match the actual rendered row height (same as --st-row-height).

Inputs

InputTypeDefaultDescription
dataSourceT[] | Observable<T[]>—Row data. Required.
tableColumnsColumnDef[]—Column definitions. Required.
tableConfigTableConfig{}Pagination, scroll, toolbar, drag/resize flags.
tableIdstring—Unique key for StStateStoringDirective persistence.
lengthnumber0Total row count for the paginator (server-side).
pageIndexnumber—Sync paginator when the host resets the page (server-side).
selectedRowsT[]—Pre-select rows programmatically.
stickyHeadersbooleanfalseSticky header row.
virtualOffsetnumber0Server-side virtual scroll only. The absolute row index (0-based) of the first row in dataSource. See Virtual scroll — server-side.

Outputs

OutputPayloadDescription
pagePageEventPage or page-size change.
sortChangeSortColumn sort changed.
filterChangeMap<string, ItemParent>Apply or Clear (map keys = column key).
selectionChangeT[]Selected rows.
refreshvoidRefresh toolbar button clicked.
columnOrderChangestring[]Data column keys after header drag-reorder.
columnWidthChangeRecord<string, number>Column widths in px after resize.
virtualRangeChangeVirtualRangeServer-side virtual scroll only. Emits { start, end } as the user scrolls — the host should fetch that window and update [dataSource], [virtualOffset], and [length].

Theming

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;
}
TokenDefaultControls
--st-header-bgsurface-variantHeader row background
--st-header-coloron-surface-variantHeader row text
--st-border-coloroutline-variantAll cell and toolbar borders
--st-row-bgtransparentDefault body row background
--st-row-hover-bgsurface-container-lowHovered row background
--st-row-selected-bgsecondary-containerSelected row background
--st-sticky-cell-bgsurface-containerBody cells in sticky columns
--st-cell-coloron-surfaceBody cell text colour
--st-font-size14pxBody cell font size
--st-row-height48pxRow min-height
--st-toolbar-bgsurfaceToolbar background
--st-scrollbar-widththinScrollbar width (thin | auto | none)
--st-scrollbar-thumboutlineScrollbar thumb colour
--st-scrollbar-tracktransparentScrollbar track colour
--st-chooser-max-height320pxColumn chooser list max-height before scroll
--st-filter-popup-bgsurfaceFilter dropdown background
--st-filter-popup-borderoutline-variantFilter dropdown border
--st-filter-header-bgsurface-variantFilter dropdown header strip
--st-filter-header-coloron-surface-variantFilter dropdown header text
--st-filter-footer-bgsurface-variantFilter dropdown footer strip
--st-filter-footer-borderoutline-variantFilter dropdown footer top border
--st-filter-input-bgsurfaceFilter search input background
--st-filter-input-borderoutlineFilter search input border

Dark mode

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

Column filters

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.

Actions

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>

Action positions

PositionWhere it renders
toolbarLeft side of the toolbar row, alongside the column-chooser and export icons
aboveSame toolbar row, rendered before toolbar actions
belowLeft side of the paginator row
row-inlineIcon button visible on every row in a sticky-end column
row-menuItem inside the ⋯ overflow menu in the same sticky column

TableAction<T> properties

PropertyTypeDescription
idstringRequired. Unique key; used as track id.
positionActionPositionRequired. Where the action renders.
cb(row: T | undefined) => voidRequired. Called on click. row is the clicked row for row positions, undefined otherwise.
labelstringButton label. Omit for an icon-only button (tooltip falls back to id).
iconstringMaterial 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) => booleanReturn true to disable.

Unknown-total pagination

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>

Virtual scroll

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) or maxHeight.
  • virtualRowHeight must equal the actual rendered row height in pixels (default 48, matching --st-row-height).

Client-side virtual scroll

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>

Server-side virtual scroll

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.

State persistence

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.

Export

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

Running the project locally

Prerequisites

  • Node.js 20+
  • npm 10+

Clone and run

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:4200

The demo runs against an in-memory interceptor — no backend required.

Library development

# In one terminal — rebuild the library on every change
npm run build:lib:watch

# In another terminal — serve the demo app
npm start

Changes to projects/ngx-mat-simple-table/ are picked up automatically; refresh the browser to see them.

Build for production

npm run build       # builds lib + demo app

Publishing the library

npm run publish:lib # runs build:lib then publishes dist/ngx-mat-simple-table to npm

Roadmap

v1.3

  • TableConfig.scrollbarVisibility: 'auto' | 'always' | 'hover'
  • Inline row editing
  • Storybook stories
  • Unit test suite

License

MIT

Blog Post

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.


Release Notes

v1.2.2

  • Declarative actions API — TableAction<T>[] with five