<c-data-table>
Examples
<template>
<div>
<!-- Drag the handle in the wrapper's bottom-right corner: with `autohide`,
columns that stop fitting move into the expansion row (rightmost
first). The pinned column never hides. Without `autohide` the table
would scroll horizontally instead. -->
<div class="resizable">
<c-data-table :columns.prop="columns" :data.prop="data" autohide />
</div>
</div>
</template>
<script setup lang="ts">
import type { CDataTableColumn } from '@cscfi/csc-ui';
const columns: CDataTableColumn[] = [
{ header: 'Project', key: 'name', pinned: 'left' },
{ header: 'Owner', key: 'owner' },
{ header: 'Facility', key: 'facility' },
{ header: 'Quota', key: 'quota' },
{ header: 'Created', key: 'created' },
];
const data = [
{
created: '2026-01-14',
facility: 'Puhti',
name: 'Aurora',
owner: 'aino.virtanen@example.fi',
quota: '20 TB',
},
{
created: '2026-02-02',
facility: 'Mahti',
name: 'Borealis',
owner: 'eero.korhonen@example.fi',
quota: '5 TB',
},
{
created: '2026-02-19',
facility: 'LUMI',
name: 'Cirrus',
owner: 'sofia.laine@example.fi',
quota: '80 TB',
},
];
</script>
<style scoped>
.resizable {
max-width: 100%;
min-width: 320px;
overflow: auto;
resize: horizontal;
width: 560px;
}
</style>
<template>
<div>
<c-data-table
:columns.prop="columns"
:data.prop="data"
:sort.prop="sort"
page-size="5"
@change:sort="onSort"
/>
<p>Sorted by: {{ sort.column }} ({{ sort.direction }})</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { CDataTableColumn, CDataTableSort } from '@cscfi/csc-ui';
const columns: CDataTableColumn[] = [
{ header: 'Project', key: 'name', sortable: true },
{ align: 'end', header: 'Members', key: 'members', sortable: true },
{ header: 'Facility', key: 'facility' },
{ header: 'Created', key: 'created', sortable: true },
];
const data = [
{ created: '2026-01-14', facility: 'Puhti', members: 12, name: 'Aurora' },
{ created: '2026-02-02', facility: 'Mahti', members: 3, name: 'Borealis' },
{ created: '2026-02-19', facility: 'LUMI', members: 41, name: 'Cirrus' },
{ created: '2026-03-05', facility: 'Allas', members: 7, name: 'Drift' },
{ created: '2026-03-28', facility: 'Puhti', members: 18, name: 'Ember' },
{ created: '2026-04-11', facility: 'LUMI', members: 2, name: 'Fjord' },
{ created: '2026-05-01', facility: 'Mahti', members: 25, name: 'Glacier' },
{ created: '2026-05-23', facility: 'Allas', members: 9, name: 'Halo' },
];
const sort = ref<CDataTableSort>({ column: 'name', direction: 'asc' });
const onSort = (event: Event) => {
sort.value = (event as CustomEvent<CDataTableSort>).detail;
};
</script>
<template>
<div>
<c-data-table :columns.prop="columns" :data.prop="data" />
<p>{{ message }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { type CDataTableColumn, h } from '@cscfi/csc-ui';
const message = ref('Open a project with the button in the last column');
const data = [
{ name: 'Aurora', status: 'active', usage: 0.72 },
{ name: 'Borealis', status: 'closed', usage: 0.13 },
{ name: 'Cirrus', status: 'pending', usage: 0.44 },
];
// Cell renderers are plain functions returning VNodes built with the `h`
// re-exported from @cscfi/csc-ui — no direct vue dependency needed.
const columns: CDataTableColumn[] = [
{ header: 'Project', key: 'name' },
{
cell: ({ value }) =>
h('c-tag', { active: value === 'active' }, String(value)),
header: 'Status',
key: 'status',
},
{
cell: ({ value }) =>
h('c-progress-bar', {
style: 'width: 160px',
value: Math.round((value as number) * 100),
}),
header: 'Usage',
key: 'usage',
},
{
align: 'end',
cell: ({ row }) =>
h(
'c-button',
{
onClick: () => {
message.value = `Opening project ${row.name}…`;
},
size: 'small',
text: true,
},
'Open',
),
header: '',
key: 'actions',
},
];
</script>
<template>
<div>
<!-- The description column has expansion: 'always' — it never renders as
a table column, its cells live in the expansion row. The custom
expandedContent renders after them. -->
<c-data-table
:columns.prop="columns"
:data.prop="data"
:expanded-content.prop="expandedContent"
:get-row-id.prop="getRowId"
single-expansion
@change:expanded="onExpanded"
/>
<p>Expanded: {{ expanded.length ? expanded.join(', ') : '—' }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import {
type CDataTableColumn,
type CDataTableExpandedContext,
type CDataTableRow,
h,
} from '@cscfi/csc-ui';
const columns: CDataTableColumn[] = [
{ header: 'Service', key: 'name' },
{ header: 'Category', key: 'category' },
{ expansion: 'always', header: 'Description', key: 'description' },
];
const data = [
{
category: 'Computing',
description:
'Supercomputer for medium-scale simulations and data analysis.',
id: 'puhti',
name: 'Puhti',
},
{
category: 'Computing',
description: 'Supercomputer for massively parallel workloads.',
id: 'mahti',
name: 'Mahti',
},
{
category: 'Storage',
description: 'Object storage for research data, accessible everywhere.',
id: 'allas',
name: 'Allas',
},
];
const getRowId = (row: CDataTableRow) => row.id as string;
const expandedContent = ({ row }: CDataTableExpandedContext) =>
h(
'c-link',
{
href: `https://docs.csc.fi/computing/systems-${row.id}/`,
underline: true,
style: 'padding-inline: 6px',
},
`Read more about ${row.name}`,
);
const expanded = ref<string[]>([]);
const onExpanded = (event: Event) => {
expanded.value = (event as CustomEvent<string[]>).detail;
};
</script>
<template>
<div>
<!-- With `external`, the table renders `data` verbatim and only emits
state changes; sorting and paging here go through a simulated server
request. `item-count` tells the pager the true total. -->
<c-data-table
:columns.prop="columns"
:data.prop="page"
:item-count="TOTAL"
:loading="loading"
:page="query.page"
:sort.prop="query.sort"
external
page-size="5"
@change:page="onPage"
@change:page-size="onPageSize"
@change:sort="onSort"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type {
CDataTableColumn,
CDataTableRow,
CDataTableSort,
} from '@cscfi/csc-ui';
const columns: CDataTableColumn[] = [
{ header: 'Job', key: 'name', sortable: true },
{ align: 'end', header: 'Runtime (h)', key: 'runtime', sortable: true },
{ header: 'State', key: 'state' },
];
// ---- a pretend server ------------------------------------------------
const TOTAL = 57;
const allRows = Array.from({ length: TOTAL }, (_, i) => ({
name: `job-${String(i + 1).padStart(3, '0')}`,
runtime: ((i * 13) % 96) + 1,
state: i % 4 ? 'completed' : 'running',
}));
const fetchPage = (q: {
page: number;
pageSize: number;
sort: CDataTableSort | null;
}): Promise<CDataTableRow[]> =>
new Promise((resolve) => {
const sorted = [...allRows].sort((a, b) => {
if (!q.sort) return 0;
const { column, direction } = q.sort;
const va = a[column as keyof typeof a];
const vb = b[column as keyof typeof b];
return (va < vb ? -1 : va > vb ? 1 : 0) * (direction === 'asc' ? 1 : -1);
});
const start = (q.page - 1) * q.pageSize;
setTimeout(() => resolve(sorted.slice(start, start + q.pageSize)), 600);
});
// -----------------------------------------------------------------------
const query = ref({
page: 1,
pageSize: 5,
sort: { column: 'name', direction: 'asc' } as CDataTableSort | null,
});
const page = ref<CDataTableRow[]>([]);
const loading = ref(false);
const load = async () => {
loading.value = true;
page.value = await fetchPage(query.value);
loading.value = false;
};
const onSort = (event: Event) => {
query.value.sort = (event as CustomEvent<CDataTableSort>).detail;
query.value.page = 1;
load();
};
const onPage = (event: Event) => {
query.value.page = (event as CustomEvent<number>).detail;
load();
};
const onPageSize = (event: Event) => {
query.value.pageSize = (event as CustomEvent<number>).detail;
load();
};
onMounted(load);
</script>
<template>
<div>
<!-- With client-side data and pagination, selecting a full page offers a
two-step "select all N rows" banner. -->
<c-data-table
:columns.prop="columns"
:data.prop="data"
:get-row-id.prop="getRowId"
:selected.prop="selected"
page-size="4"
selection="multiple"
@change:selected="onSelection"
/>
<p>Selected ids: {{ selected.length ? selected.join(', ') : '—' }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import type { CDataTableColumn, CDataTableRow } from '@cscfi/csc-ui';
const columns: CDataTableColumn[] = [
{ header: 'Dataset', key: 'name' },
{ align: 'end', header: 'Size (GB)', key: 'size' },
];
const data = Array.from({ length: 11 }, (_, i) => ({
id: `ds-${i + 1}`,
name: `Dataset ${i + 1}`,
size: ((i * 37) % 90) + 4,
}));
// A stable row id keeps the selection correct across sorting and paging.
const getRowId = (row: CDataTableRow) => row.id as string;
const selected = ref<string[]>(['ds-2']);
const onSelection = (event: Event) => {
selected.value = (
event as CustomEvent<{ ids: string[]; rows: CDataTableRow[] }>
).detail.ids;
};
</script>
API reference
<c-data-table>
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
autohide | autohide | boolean | false | Move overflowing `expansion: 'auto'` columns into the expansion row (rightmost first) instead of scrolling horizontally. |
columns | — | | () => [] | Column definitions. Pass as a DOM property (contains functions). |
data | — | | () => [] | Rows — plain domain objects. Pass as a DOM property. |
expanded | — | string[] | — | Ids of the expanded rows (optionally controlled). |
expandedContent | — | ( context: | — | Custom expansion-row content, appended after the auto-rendered cells of columns currently in the expansion row. |
external | external | boolean | false | The server owns sorting, pagination and filtering: the table renders `data` verbatim and only emits the state-change events. Requires `itemCount` for the pager; disables the select-all banner and `filter`. |
filter | filter | string | '' | Filter rows client-side against this string (all columns). Ignored when `external` is set. |
getRowId | — | (row: | — | Return a stable id for a row. Falls back to the row's index — supply this whenever selection/expansion is used with `external` data. |
itemCount | item-count | number | — | Total number of rows in the dataset. Only used (and needed) with `external`. |
loading | loading | boolean | false | Show the loading indicator. |
page | page | number | — | Current page, 1-based (optionally controlled). |
pageSize | page-size | number | — | Rows per page. Pagination is active only when set — without it every row renders and no pager is shown. |
pageSizes | — | number[] | () => [5, 25, 50, 100] | Options for the pager's page-size menu. |
selected | — | string[] | — | Ids of the selected rows (optionally controlled). |
selection | selection | CDataTableSelectionMode | — | Row selection mode. Unset means rows are not selectable. |
singleExpansion | single-expansion | boolean | false | Allow only one row to be expanded at a time. |
sort | sort | | — | The sorting state (optionally controlled). `null` renders unsorted. |
stickyFooter | sticky-footer | boolean | false | Keep the footer row visible while the table scrolls vertically. |
stickyHeader | sticky-header | boolean | false | Keep the header row visible while the table scrolls vertically. |
texts | — | | () => ({}) | UI text overrides (i18n), merged over the English defaults. |
Events
| Event | Detail | Description |
|---|---|---|
change:expanded | string[] | Fired when the expanded rows change, carrying the expanded row ids. |
change:page | number | Fired when the user changes the page, carrying the new 1-based page. |
change:page-size | number | Fired when the user picks a new page size. Also resets the page to 1 (a separate `change:page` event fires alongside). |
change:selected | { ids: string[]; rows: CDataTableRow[] } | Fired when the selection changes, carrying the selected row ids and the row objects resolvable from the current `data`. |
change:sort | CDataTableSort | null | Fired when the user sorts a column, carrying the new sorting state. |
Slots
| Slot | Description |
|---|---|
caption | Table caption, rendered into the native `<caption>` element |
empty | Empty-state content shown when there are no rows |
CSS parts
Style from outside with c-data-table::part(name) — parts are the library's only styling customization API.
| Part | Description |
|---|---|
root | The outer wrapper around the table and pagination |
banner | The two-step select-all banner |
viewport | The scroll container around the table |
table | The `<table>` element |
caption | The `<caption>` element |
header | The `<thead>` element |
header-cell | A data column's `<th>` |
body | The `<tbody>` element |
row | A data `<tr>` |
cell | A data column's `<td>` |
expansion-row | The expansion `<tr>` revealed beneath a data row |
footer | The `<tfoot>` element (footer columns) |
empty | The empty-state cell content |
pagination | The pagination bar below the table |
Types
Importable from the package root: import type { … } from '@cscfi/csc-ui'
CDataTableAlign
Horizontal alignment of a column's header and cell content.
export type CDataTableAlign = 'center' | 'end' | 'start';CDataTableCellContent
Content a data-table render function may return: a VNode (create with the `h` re-exported from this package), a plain string/number rendered as text, or an array of these. Strings render as text, never as HTML.
export type CDataTableCellContent = VNodeChild;CDataTableCellContext
Context handed to a column's `cell` render function.
export interface CDataTableCellContext {
/** The column being rendered. */
column: CDataTableColumn;
/** The row object this cell belongs to. */
row: CDataTableRow;
/** Stable row id (from `getRowId`, or the row index). */
rowId: string;
/** Index of the row within the full data set. */
rowIndex: number;
/** The raw cell value (`row[column.key]`). */
value: unknown;
}CDataTableColumn
A column definition — the component-owned column API. Mapped to TanStack's `ColumnDef` internally; TanStack types never leak to consumers.
export interface CDataTableColumn {
/** Horizontal alignment of the header and cell content. */
align?: CDataTableAlign;
/**
* Custom cell renderer. Return a VNode built with the package-exported `h`,
* or a string/number rendered as text. Omit to render the raw value.
*/
cell?: (context: CDataTableCellContext) => CDataTableCellContent;
/**
* When this column's cells move to the expansion row: `auto` (default,
* moved only when autohide overflows), `never`, or `always`.
*/
expansion?: CDataTableColumnExpansion;
/**
* Footer cell renderer. The footer row renders only when at least one
* column defines one.
*/
footer?: (context: CDataTableFooterContext) => CDataTableCellContent;
/** Header content — a string, or a render function for rich headers. Defaults to `key`. */
header?: (() => CDataTableCellContent) | string;
/** Key of the row property this column reads (also the column's id). */
key: string;
/**
* Pin the column to an edge so it stays visible during horizontal scroll.
* A pinned column is never autohidden. Cannot combine with
* `expansion: 'always'`.
*/
pinned?: CDataTableColumnPin;
/** Allow sorting by this column. */
sortable?: boolean;
/** Fixed column width (any CSS width value). */
width?: string;
}CDataTableColumnExpansion
When a column's cells live in the expansion row: `auto` (only when autohide overflows — the default), `never` (always a real column), `always` (never a real column). See CONTEXT.md → "Expansion policy".
export type CDataTableColumnExpansion = 'always' | 'auto' | 'never';CDataTableColumnPin
Side a column is pinned to. A pinned column sticks to the table edge during horizontal scroll and is never autohidden. Not the old Stencil meaning — see CONTEXT.md → "Pinned column".
export type CDataTableColumnPin = 'left' | 'right';CDataTableExpandedContext
Context handed to the table-level `expandedContent` render function.
export interface CDataTableExpandedContext {
/** Columns currently rendered inside the expansion row (policy `always` + autohidden). */
expansionColumns: CDataTableColumn[];
/** The expanded row object. */
row: CDataTableRow;
/** Stable row id (from `getRowId`, or the row index). */
rowId: string;
/** Index of the row within the full data set. */
rowIndex: number;
}CDataTableFooterContext
Context handed to a column's `footer` render function.
export interface CDataTableFooterContext {
/** The column whose footer is being rendered. */
column: CDataTableColumn;
/** The rows currently rendered (the visible page). */
rows: CDataTableRow[];
}CDataTableProps
export interface CDataTableProps {
/**
* Move overflowing `expansion: 'auto'` columns into the expansion row
* (rightmost first) instead of scrolling horizontally.
*/
autohide?: boolean;
/** Column definitions. Pass as a DOM property (contains functions). */
columns?: CDataTableColumn[];
/** Rows — plain domain objects. Pass as a DOM property. */
data?: CDataTableRow[];
/** Ids of the expanded rows (optionally controlled). */
expanded?: string[];
/**
* Custom expansion-row content, appended after the auto-rendered cells of
* columns currently in the expansion row.
*/
expandedContent?: (
context: CDataTableExpandedContext,
) => CDataTableCellContent;
/**
* The server owns sorting, pagination and filtering: the table renders
* `data` verbatim and only emits the state-change events. Requires
* `itemCount` for the pager; disables the select-all banner and `filter`.
*/
external?: boolean;
/**
* Filter rows client-side against this string (all columns). Ignored when
* `external` is set.
*
* @freeform
*/
filter?: string;
/**
* Return a stable id for a row. Falls back to the row's index — supply
* this whenever selection/expansion is used with `external` data.
*/
getRowId?: (row: CDataTableRow) => string;
/** Total number of rows in the dataset. Only used (and needed) with `external`. */
itemCount?: number;
/** Show the loading indicator. */
loading?: boolean;
/** Current page, 1-based (optionally controlled). */
page?: number;
/**
* Rows per page. Pagination is active only when set — without it every row
* renders and no pager is shown.
*/
pageSize?: number;
/** Options for the pager's page-size menu. */
pageSizes?: number[];
/** Ids of the selected rows (optionally controlled). */
selected?: string[];
/** Row selection mode. Unset means rows are not selectable. */
selection?: CDataTableSelectionMode;
/** Allow only one row to be expanded at a time. */
singleExpansion?: boolean;
/** The sorting state (optionally controlled). `null` renders unsorted. */
sort?: CDataTableSort | null;
/** Keep the footer row visible while the table scrolls vertically. */
stickyFooter?: boolean;
/** Keep the header row visible while the table scrolls vertically. */
stickyHeader?: boolean;
/** UI text overrides (i18n), merged over the English defaults. */
texts?: CDataTableTexts;
}CDataTableRow
A single data row — the consumer's own plain domain object.
export type CDataTableRow = Record<string, unknown>;CDataTableSelectionMode
Row selection mode. Unset means rows are not selectable.
export type CDataTableSelectionMode = 'multiple' | 'single';CDataTableSort
The table's atomic sorting state.
export interface CDataTableSort {
/** `key` of the sorted column. */
column: string;
/** Direction the column is sorted in. */
direction: CDataTableSortDirection;
}CDataTableSortDirection
Sorting direction of a column.
export type CDataTableSortDirection = 'asc' | 'desc';CDataTableTexts
UI texts, shallow-merged over the English defaults. Static labels are strings; count-interpolated ones are functions.
export interface CDataTableTexts {
/** Banner text when every (filtered) row is selected. */
allSelected?: (count: number) => string;
/** Label of the banner action clearing the whole selection. */
clearSelection?: string;
/** Accessible label of a row's expansion toggle. */
expandRow?: string;
/** Text shown when the table is empty while `loading`. */
loading?: string;
/** Text shown when there are no rows (unless the `empty` slot is used). */
noData?: string;
/** Banner text when the visible page is fully selected. */
pageSelected?: (count: number) => string;
/** Label of the banner action selecting all (filtered) rows. */
selectAllItems?: (count: number) => string;
/** Accessible label of the header select-all checkbox. */
selectPage?: string;
/** Accessible label of a row's selection checkbox. */
selectRow?: string;
}