Data Table
Sort, filter, page and select rows. Use one feature or all, in the browser or on your server.
import { … } from '@inlaylab/ivorie/data-table'| Status | |||
|---|---|---|---|
| ada@example.com | $40.00 | ||
| alan@example.com | $177.00 | ||
| grace@example.com | $314.00 | ||
| katherine@example.com | $451.00 | ||
| linus@example.com | $588.00 | ||
| margaret@example.com | $725.00 | ||
| dennis@example.com | $862.00 | ||
| barbara@example.com | $99.00 | ||
| ken@example.com | $236.00 | ||
| radia@example.com | $373.00 |
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IvBadge } from '@inlaylab/ivorie/badge';
import { IV_DATA_TABLE_IMPORTS, IvColumnDef, ivDataTable } from '@inlaylab/ivorie/data-table';
import { IV_INPUT_IMPORTS } from '@inlaylab/ivorie/input';
interface Payment {
id: string;
email: string;
amount: number;
status: 'paid' | 'pending' | 'failed';
}
const NAMES = [
'ada',
'alan',
'grace',
'katherine',
'linus',
'margaret',
'dennis',
'barbara',
'ken',
'radia',
];
const STATUSES = ['paid', 'pending', 'paid', 'failed', 'paid'] as const;
const PAYMENTS: Payment[] = Array.from({ length: 25 }, (_, i) => ({
id: `pay_${i + 1}`,
email: `${NAMES[i % NAMES.length]}${i >= NAMES.length ? i : ''}@example.com`,
amount: 40 + ((i * 137) % 900),
status: STATUSES[i % STATUSES.length],
}));
const COLUMNS: IvColumnDef<Payment>[] = [
{ id: 'status', header: 'Status', accessor: (p) => p.status, hideable: true },
{ id: 'email', header: 'Email', accessor: (p) => p.email, sortable: true, hideable: true },
{ id: 'amount', header: 'Amount', accessor: (p) => p.amount, sortable: true, hideable: true },
];
@Component({
selector: 'demo-data-table',
imports: [IV_DATA_TABLE_IMPORTS, IV_INPUT_IMPORTS, IvBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full max-w-3xl flex-col gap-4">
<div class="flex items-center gap-2">
<label ivLabel for="payments-filter" class="sr-only">Filter emails</label>
<input
ivInput
id="payments-filter"
class="max-w-sm"
placeholder="Filter emails…"
[value]="table.filter.globalFilter()"
(input)="table.filter.setFilter($any($event.target).value)"
/>
<iv-data-table-view-options class="ml-auto" [visibility]="table.visibility" />
</div>
<iv-table class="rounded-lg border">
<thead ivTableHeader>
<tr ivTableRow>
<th ivTableHead class="w-10">
<iv-data-table-select-all [selection]="table.selection" />
</th>
@if (table.visibility.isVisible('status')) {
<th ivTableHead>Status</th>
}
@if (table.visibility.isVisible('email')) {
<th ivDataTableSort="email" [sort]="table.sort">Email</th>
}
@if (table.visibility.isVisible('amount')) {
<th ivDataTableSort="amount" [sort]="table.sort" class="text-right">Amount</th>
}
</tr>
</thead>
<tbody
ivDataTableBody
[rows]="table.rows()"
[colspan]="table.visibility.columns().length + 1"
>
@for (p of table.rows(); track p.id) {
<tr ivTableRow [ivDataTableRow]="p.id" [selection]="table.selection">
<td ivTableCell>
<iv-data-table-row-select
[selection]="table.selection"
[rowId]="p.id"
[aria-label]="'Select ' + p.email"
/>
</td>
@if (table.visibility.isVisible('status')) {
<td ivTableCell>
<iv-badge
[variant]="
p.status === 'failed'
? 'destructive'
: p.status === 'pending'
? 'outline'
: 'secondary'
"
>{{ p.status }}</iv-badge
>
</td>
}
@if (table.visibility.isVisible('email')) {
<td ivTableCell>{{ p.email }}</td>
}
@if (table.visibility.isVisible('amount')) {
<td ivTableCell class="text-right font-medium tabular-nums">
{{ usd.format(p.amount) }}
</td>
}
</tr>
}
</tbody>
</iv-table>
<iv-data-table-pagination [pagination]="table.pagination" [selection]="table.selection" />
</div>
`,
})
export default class DataTableDemo {
protected readonly usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
protected readonly table = ivDataTable({
data: signal(PAYMENTS),
columns: COLUMNS,
getRowId: (p) => p.id,
});
}
Installation
npx ivorie add data-tableimport { … } from '@inlaylab/ivorie/data-table'Built on iv-table, in the spirit of the shadcn/ui data table. The state lives in five standalone features. Each takes rows and returns its state plus rows for the next one, like TanStack row models. The parts take only the feature they need, like MatSort and MatPaginator. You write the cells.
Usage
import { IV_DATA_TABLE_IMPORTS, ivDataTable } from '@inlaylab/ivorie/data-table';
readonly table = ivDataTable({ data: this.payments, columns, getRowId: (p) => p.id });<iv-table>
<thead ivTableHeader>
<tr ivTableRow>
<th ivDataTableSort="email" [sort]="table.sort">Email</th>
</tr>
</thead>
<tbody ivDataTableBody [rows]="table.rows()" [colspan]="1">
@for (p of table.rows(); track p.id) {
<tr ivTableRow><td ivTableCell>{{ p.email }}</td></tr>
}
</tbody>
</iv-table>
<iv-data-table-pagination [pagination]="table.pagination" />One feature at a time
Chain only what you need. Each function takes a Signal<T[]> and returns rows.
const filter = ivFilter(this.data, { columns });
const sort = ivSort(filter.rows, { columns });
const pages = ivPagination(sort.rows, { pageSize: 20, resetOn: () => [filter.globalFilter(), filter.columnFilters()] });
const selection = ivRowSelection(pages.rows, { getRowId: (r) => r.id });They are plain functions with no injection, so call them anywhere: in a field, a service or a test.
Examples
Sorting
| 1.0.0 | First release | Sep 30, 2026 |
| 1.1.0 | More components | Oct 9, 2026 |
| 1.2.0 | Playground | Oct 21, 2026 |
| 1.10.0 | Later work | Jun 1, 2027 |
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IV_DATA_TABLE_IMPORTS, IvColumnDef, ivSort } from '@inlaylab/ivorie/data-table';
interface Release {
version: string;
name: string;
date: Date;
}
const RELEASES: Release[] = [
{ version: '1.2.0', name: 'Playground', date: new Date(2026, 9, 21) },
{ version: '1.0.0', name: 'First release', date: new Date(2026, 8, 30) },
{ version: '1.10.0', name: 'Later work', date: new Date(2027, 5, 1) },
{ version: '1.1.0', name: 'More components', date: new Date(2026, 9, 9) },
];
const COLUMNS: IvColumnDef<Release>[] = [
{ id: 'version', header: 'Version', accessor: (r) => r.version, sortable: true },
{ id: 'name', header: 'Name', accessor: (r) => r.name, sortable: true },
{ id: 'date', header: 'Date', accessor: (r) => r.date, sortable: true },
];
/** Only sorting: ivSort on its own, no pagination or selection. Versions sort as numbers (1.10 after 1.2). */
@Component({
selector: 'demo-data-table-sorting',
imports: [IV_DATA_TABLE_IMPORTS],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<iv-table class="w-[28rem] rounded-lg border">
<thead ivTableHeader>
<tr ivTableRow>
@for (c of columns; track c.id) {
<th [ivDataTableSort]="c.id" [sort]="sort">{{ c.header }}</th>
}
</tr>
</thead>
<tbody ivTableBody>
@for (r of sort.rows(); track r.version) {
<tr ivTableRow>
<td ivTableCell class="font-mono">{{ r.version }}</td>
<td ivTableCell>{{ r.name }}</td>
<td ivTableCell class="tabular-nums">{{ date.format(r.date) }}</td>
</tr>
}
</tbody>
</iv-table>
`,
})
export default class DataTableSortingDemo {
protected readonly columns = COLUMNS;
protected readonly date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' });
protected readonly sort = ivSort(signal(RELEASES), {
columns: COLUMNS,
initial: { id: 'version', desc: false },
});
}
Row selection
| Sent | ||
|---|---|---|
| ada@example.com | 2 days ago | |
| alan@example.com | 5 days ago | |
| grace@example.com | 1 week ago | |
| linus@example.com | 3 weeks ago |
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DATA_TABLE_IMPORTS, ivRowSelection } from '@inlaylab/ivorie/data-table';
interface Invite {
email: string;
sent: string;
}
/** Only selection: ivRowSelection on its own, with a bulk action. */
@Component({
selector: 'demo-data-table-row-selection',
imports: [IV_DATA_TABLE_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-[28rem] flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<span class="text-muted-foreground text-sm">{{ invites().length }} pending invites</span>
<button ivButton variant="destructive" size="sm" [disabled]="!count()" (click)="revoke()">
{{ count() ? 'Revoke ' + count() : 'Revoke' }}
</button>
</div>
<iv-table class="rounded-lg border">
<thead ivTableHeader>
<tr ivTableRow>
<th ivTableHead class="w-10"><iv-data-table-select-all [selection]="selection" /></th>
<th ivTableHead>Email</th>
<th ivTableHead>Sent</th>
</tr>
</thead>
<tbody ivDataTableBody [rows]="invites()" [colspan]="3">
@for (i of invites(); track i.email) {
<tr ivTableRow [ivDataTableRow]="i.email" [selection]="selection">
<td ivTableCell>
<iv-data-table-row-select
[selection]="selection"
[rowId]="i.email"
[aria-label]="'Select ' + i.email"
/>
</td>
<td ivTableCell>{{ i.email }}</td>
<td ivTableCell class="text-muted-foreground">{{ i.sent }}</td>
</tr>
}
<span ivDataTableEmpty>No pending invites.</span>
</tbody>
</iv-table>
</div>
`,
})
export default class DataTableRowSelectionDemo {
protected readonly invites = signal<Invite[]>([
{ email: 'ada@example.com', sent: '2 days ago' },
{ email: 'alan@example.com', sent: '5 days ago' },
{ email: 'grace@example.com', sent: '1 week ago' },
{ email: 'linus@example.com', sent: '3 weeks ago' },
]);
protected readonly selection = ivRowSelection(this.invites, { getRowId: (i) => i.email });
protected readonly count = computed(
() => this.invites().filter((i) => this.selection.isSelected(i.email)).length,
);
protected revoke(): void {
this.invites.update((all) => all.filter((i) => !this.selection.isSelected(i.email)));
this.selection.clear();
}
}
Server mode
| #1001 | Acme | $20.00 |
| #1002 | Stark | $109.00 |
| #1003 | Initech | $198.00 |
| #1004 | Wonka | $287.00 |
| #1005 | Hooli | $376.00 |
import { ChangeDetectionStrategy, Component, computed, linkedSignal, resource } from '@angular/core';
import {
IV_DATA_TABLE_IMPORTS,
IvColumnDef,
IvDataTable,
IvDataTableQuery,
ivDataTable,
} from '@inlaylab/ivorie/data-table';
import { IV_INPUT_IMPORTS } from '@inlaylab/ivorie/input';
interface Order {
id: number;
customer: string;
total: number;
}
const COLUMNS: IvColumnDef<Order>[] = [
{ id: 'id', header: 'Order', sortable: true },
{ id: 'customer', header: 'Customer', sortable: true },
{ id: 'total', header: 'Total', sortable: true },
];
// A fake backend with 100 orders and 600 ms of latency. In an app this is your API,
// for example httpResource(() => ({ url: '/api/orders', params: … })).
const CUSTOMERS = ['Acme', 'Globex', 'Initech', 'Umbrella', 'Hooli', 'Stark', 'Wayne', 'Wonka'];
const ORDERS: Order[] = Array.from({ length: 100 }, (_, i) => ({
id: 1001 + i,
customer: CUSTOMERS[(i * 5) % CUSTOMERS.length],
total: 20 + ((i * 89) % 480),
}));
interface OrderPage {
rows: Order[];
total: number;
}
function fetchOrders(q: IvDataTableQuery, abortSignal: AbortSignal): Promise<OrderPage> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const term = q.globalFilter.trim().toLowerCase();
let rows = ORDERS.filter(
(o) => !term || o.customer.toLowerCase().includes(term) || String(o.id).includes(term),
);
const s = q.sorting;
if (s) {
const key = s.id as keyof Order;
rows = [...rows].sort(
(a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0) * (s.desc ? -1 : 1),
);
}
const { pageIndex, pageSize } = q.pagination;
resolve({
rows: rows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
total: rows.length,
});
}, 600);
abortSignal.addEventListener('abort', () => {
clearTimeout(timer);
reject(abortSignal.reason);
});
});
}
/** Server mode: the table keeps the state, the server filters, sorts and pages. */
@Component({
selector: 'demo-data-table-server',
imports: [IV_DATA_TABLE_IMPORTS, IV_INPUT_IMPORTS],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-full max-w-2xl flex-col gap-4">
<label ivLabel for="orders-filter" class="sr-only">Search orders</label>
<input
ivInput
id="orders-filter"
class="max-w-xs"
placeholder="Search customer or order…"
(input)="table.filter.setFilter($any($event.target).value)"
/>
<iv-table class="rounded-lg border">
<thead ivTableHeader>
<tr ivTableRow>
@for (c of columns; track c.id) {
<th [ivDataTableSort]="c.id" [sort]="table.sort">{{ c.header }}</th>
}
</tr>
</thead>
<tbody
ivDataTableBody
[rows]="table.rows()"
[colspan]="3"
[loading]="orders.isLoading()"
[loadingRows]="table.pagination.pagination().pageSize"
>
@for (o of table.rows(); track o.id) {
<tr ivTableRow>
<td ivTableCell class="font-mono">#{{ o.id }}</td>
<td ivTableCell>{{ o.customer }}</td>
<td ivTableCell class="tabular-nums">{{ usd.format(o.total) }}</td>
</tr>
}
</tbody>
</iv-table>
<iv-data-table-pagination [pagination]="table.pagination" [pageSizes]="[5, 10, 20]" />
</div>
`,
})
export default class DataTableServerDemo {
protected readonly columns = COLUMNS;
protected readonly usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
// While the next page loads, the resource has no value: keep showing the last page and total,
// so the page count and the buttons do not jump.
private readonly page = linkedSignal<OrderPage | undefined, OrderPage>({
source: () => (this.orders.hasValue() ? this.orders.value() : undefined),
computation: (next, previous) => next ?? previous?.value ?? { rows: [], total: 0 },
});
// The table and the resource read each other, so their types are written out.
protected readonly table: IvDataTable<Order> = ivDataTable<Order>({
mode: 'server',
data: computed(() => this.page().rows),
rowCount: computed(() => this.page().total),
columns: COLUMNS,
getRowId: (o) => String(o.id),
pageSize: 5,
});
protected readonly orders = resource<OrderPage, IvDataTableQuery>({
params: () => this.table.query(),
loader: ({ params, abortSignal }) => fetchOrders(params, abortSignal),
});
}
Loading and empty
| Project | Owner | Updated |
|---|---|---|
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { IvButton } from '@inlaylab/ivorie/button';
import { IV_DATA_TABLE_IMPORTS } from '@inlaylab/ivorie/data-table';
/** Loading and empty states of the table body, with custom empty content. */
@Component({
selector: 'demo-data-table-states',
imports: [IV_DATA_TABLE_IMPORTS, IvButton],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex w-[28rem] flex-col gap-3">
<div class="flex gap-2">
<button
ivButton
size="sm"
[variant]="loading() ? 'default' : 'outline'"
[attr.aria-pressed]="loading()"
(click)="loading.set(true)"
>
Loading
</button>
<button
ivButton
size="sm"
[variant]="loading() ? 'outline' : 'default'"
[attr.aria-pressed]="!loading()"
(click)="loading.set(false)"
>
Empty
</button>
</div>
<iv-table class="rounded-lg border">
<thead ivTableHeader>
<tr ivTableRow>
<th ivTableHead>Project</th>
<th ivTableHead>Owner</th>
<th ivTableHead>Updated</th>
</tr>
</thead>
<tbody ivDataTableBody [rows]="[]" [colspan]="3" [loading]="loading()" [loadingRows]="4">
<div ivDataTableEmpty class="flex flex-col items-center gap-1 py-4">
<span class="text-foreground font-medium">No projects yet</span>
<span>Projects you create show up here.</span>
</div>
</tbody>
</iv-table>
</div>
`,
})
export default class DataTableStatesDemo {
protected readonly loading = signal(true);
}
Server mode
With mode: 'server' (or manual: true on a single feature), the features keep their state but pass the rows through unchanged. Send table.query() to your API and give back one page:
readonly table = ivDataTable({ mode: 'server', data: this.page, rowCount: this.total, columns, getRowId });
readonly orders = httpResource(() => ({ url: '/api/orders', params: toParams(this.table.query()) }));query() holds sorting, pagination, globalFilter and columnFilters. A filter change sets the page back to the first one, in both modes.
While the next page loads, a resource has no value. Keep the last page and total (the server example uses a linkedSignal), so the page count and the buttons do not jump. query() never reads rowCount, so the total can come from the same resource.
API
Columns
IvColumnDef<T>: { id, header, accessor?, sortable?, sortFn?, filterFn?, hideable? }. accessor gives the value to sort and filter by. Strings sort with Intl.Collator (numeric, so 1.10 comes after 1.2). Numbers and dates sort by value. Empty values go last.
Features
| Function | Returns |
|---|---|
ivFilter(rows, { columns, manual?, initial? }) |
globalFilter, columnFilters, rows, setFilter(value), setColumnFilter(id, value) |
ivSort(rows, { columns, manual?, initial? }) |
sorting, rows, direction(id), toggleSort(id) (ascending, descending, none) |
ivPagination(rows, { pageSize?, manual?, rowCount?, resetOn? }) |
pagination, rows, rowCount, pageCount, page, canPrevious, canNext, setPage(i), setPageSize(n) |
ivRowSelection(rows, { getRowId }) |
selected, allSelected, someSelected, isSelected(id), toggle(id), toggleAll(), clear() |
ivColumnVisibility(columns, initial?) |
visibility, columns (visible), hideable, isVisible(id), setVisible(id, on) |
ivDataTable({ data, columns, getRowId, mode?, rowCount?, pageSize? }) |
filter, sort, pagination, selection, visibility, rows, query |
State values are writable signals. toggleAll() acts on the rows the selection was given, so with pagination it selects the page. The page index is clamped: when the data shrinks, the last page shows.
Parts
| Part | Inputs |
|---|---|
th[ivDataTableSort] |
ivDataTableSort (column id), sort. Use it instead of ivTableHead. |
tr[ivDataTableRow] |
ivDataTableRow (row id), selection. Sets data-state="selected". |
iv-data-table-select-all |
selection |
iv-data-table-row-select |
selection, rowId, aria-label (default Select row) |
tbody[ivDataTableBody] |
rows, colspan, loading, loadingRows (default 10). Use it instead of ivTableBody. |
iv-data-table-pagination |
pagination, selection (shows the selected count), pageSizes (default [10, 20, 30, 50]) |
iv-data-table-view-options |
visibility |
IV_DATA_TABLE_IMPORTS holds every part and the table parts.
Accessibility
- Sort headers set
aria-sortand sort with a native button, so Enter and Space work. - "Page X of Y" is a polite live region. The page buttons have labels: "Go to first page", and so on.
- While loading, the body has
aria-busy="true". - Give each row checkbox a label that names the row, for example
[aria-label]="'Select ' + p.email".
Notes
- Put custom empty content in one element with
ivDataTableEmpty, directly in the body. Do not wrap it in@if: the block fills the slot even when false, so the default text never shows. - There is no cell-renderer API. Cells are your template, so any component works in a cell.