CSC Design System next
VueFlavour Vue React Angular TypeScript PrimaryCSC UI ColorsPrimarySecondaryAccentCustom ColorsRedOrangeGreenBluePurplePink
Guides Getting started Customization Data visualization Migration guide Componentsc-accordionc-alertc-autocompletec-badgec-buttonc-button-groupc-cardc-checkboxc-csc-logoc-data-tablec-dividerc-iconc-icon-buttonc-inputc-linkc-listc-loaderc-login-buttonsc-login-cardc-mainc-menuc-messagec-modalc-navigation-buttonc-otp-inputc-pagec-paginationc-popoverc-progress-barc-progress-circlec-radio-groupc-selectc-side-navigationc-sliderc-spinnerc-statusc-stepsc-switchc-tablec-tabsc-tagsc-text-fieldc-toastsc-toolbarc-tooltip

<c-data-table>

Examples

Autohide
Vue React Angular TypeScript
<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>
Basic
Vue React Angular TypeScript
<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>
Custom cells
Vue React Angular TypeScript
<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>
Expansion
Vue React Angular TypeScript
<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>
External data
Vue React Angular TypeScript
<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>
Selection
Vue React Angular TypeScript
<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

PropertyAttributeTypeDefaultDescription
autohideautohidebooleanfalseMove overflowing `expansion: 'auto'` columns into the expansion row (rightmost first) instead of scrolling horizontally.
columnsCDataTableColumn[]() => []Column definitions. Pass as a DOM property (contains functions).
dataCDataTableRow[]() => []Rows — plain domain objects. Pass as a DOM property.
expandedstring[]Ids of the expanded rows (optionally controlled).
expandedContent( context: CDataTableExpandedContext, ) => CDataTableCellContentCustom expansion-row content, appended after the auto-rendered cells of columns currently in the expansion row.
externalexternalbooleanfalseThe 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`.
filterfilterstring''Filter rows client-side against this string (all columns). Ignored when `external` is set.
getRowId(row: CDataTableRow) => stringReturn a stable id for a row. Falls back to the row's index — supply this whenever selection/expansion is used with `external` data.
itemCountitem-countnumberTotal number of rows in the dataset. Only used (and needed) with `external`.
loadingloadingbooleanfalseShow the loading indicator.
pagepagenumberCurrent page, 1-based (optionally controlled).
pageSizepage-sizenumberRows per page. Pagination is active only when set — without it every row renders and no pager is shown.
pageSizesnumber[]() => [5, 25, 50, 100]Options for the pager's page-size menu.
selectedstring[]Ids of the selected rows (optionally controlled).
selectionselectionCDataTableSelectionModeRow selection mode. Unset means rows are not selectable.
singleExpansionsingle-expansionbooleanfalseAllow only one row to be expanded at a time.
sortsortCDataTableSort | nullThe sorting state (optionally controlled). `null` renders unsorted.
stickyFootersticky-footerbooleanfalseKeep the footer row visible while the table scrolls vertically.
stickyHeadersticky-headerbooleanfalseKeep the header row visible while the table scrolls vertically.
textsCDataTableTexts() => ({})UI text overrides (i18n), merged over the English defaults.

Events

EventDetailDescription
change:expandedstring[]Fired when the expanded rows change, carrying the expanded row ids.
change:pagenumberFired when the user changes the page, carrying the new 1-based page.
change:page-sizenumberFired 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:sortCDataTableSort | nullFired when the user sorts a column, carrying the new sorting state.

Slots

SlotDescription
captionTable caption, rendered into the native `<caption>` element
emptyEmpty-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.

PartDescription
rootThe outer wrapper around the table and pagination
bannerThe two-step select-all banner
viewportThe scroll container around the table
tableThe `<table>` element
captionThe `<caption>` element
headerThe `<thead>` element
header-cellA data column's `<th>`
bodyThe `<tbody>` element
rowA data `<tr>`
cellA data column's `<td>`
expansion-rowThe expansion `<tr>` revealed beneath a data row
footerThe `<tfoot>` element (footer columns)
emptyThe empty-state cell content
paginationThe 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;
}