DataTable

A typed data table with optional query, layout and editing capabilities.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
<script setup lang="ts">
  import { DataTable, Tag } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns, statusLabels } = tableDemo('en')
</script>

<template>
  <DataTable :rows="rows.slice(0, 4)" :columns="columns" row-key="id" label="Entries">
    <template #cell-status="{ row }">
      <Tag :tone="row.status === 'active' ? 'success' : 'neutral'">
        {{ statusLabels[row.status] }}
      </Tag>
    </template>
  </DataTable>
</template>
vue

Usage

import { DataTable, type DataTableColumn } from '@hina-ui/vue'
ts

Pass rows, columns and a stable rowKey. Only the table appears by default; every additional control is optional. Styling follows Table, including density, dark mode and RTL.

Columns, slots, callbacks and the instance API retain the original row type. Supply a new array when data changes so the data pipeline recalculates.

Examples

Cells

cell-key replaces one column’s content; cell is the shared fallback. They receive the original typed row, column, value, stable key, source index, depth and selection/expansion helpers. field reads another field and accessor takes precedence. format affects display only. A dedicated cell-key slot takes precedence over the shared cell slot.

align applies to headers and cells. rowClickable emits rowClick for click, Enter and Space; actions inside cells keep their own behavior. rowContextmenu forwards the original row and event; call event.preventDefault() when providing a custom menu.

The name column uses #cell-name to combine Avatar with two lines of Text, while #cell-status renders Tag. The shared #cell slot checks column.key to combine Progress with the count. Slots replace cell content; DataTable retains the surrounding cells, alignment and sorting.

Status
A

Entry A

ID 1

Active

18

B

Entry B

ID 2

Draft

55

C

Entry C

ID 3

Active

92

D

Entry D

ID 4

Active

129

E

Entry E

ID 5

Archived

16

<script setup lang="ts">
  import { Avatar, DataTable, Inline, Progress, Stack, Tag, Text } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns, statusLabels } = tableDemo('en')
</script>

<template>
  <DataTable :rows="rows.slice(0, 5)" :columns="columns" row-key="id" label="Entries">
    <template #cell-name="{ row }">
      <Inline gap="sm" :wrap="false" class="min-w-36 py-2">
        <Avatar :name="row.name.slice(-1)" size="sm" aria-hidden="true" />
        <Stack gap="none">
          <Text size="sm" weight="medium" class="whitespace-nowrap">{{ row.name }}</Text>
          <Text size="xs" tone="muted">ID {{ row.id }}</Text>
        </Stack>
      </Inline>
    </template>
    <template #cell-status="{ row }">
      <Tag :tone="row.status === 'active' ? 'success' : 'neutral'">
        {{ statusLabels[row.status] }}
      </Tag>
    </template>
    <template #cell="{ column, value }">
      <Inline v-if="column.key === 'count'" gap="sm" :wrap="false" class="min-w-32">
        <Progress
          :value="Number(value)"
          :max="150"
          :aria-label="column.label"
          size="sm"
          class="flex-1"
        />
        <Text size="sm" class="w-8 shrink-0 text-end tabular-nums">{{ value }}</Text>
      </Inline>
      <template v-else>{{ value }}</template>
    </template>
  </DataTable>
</template>
vue

Sorting

Set sortable: true per column. Headers cycle ascending → descending → unsorted. v-model:sorting stores { key, desc }[]; multi-sort allows Shift-click to append sorting. Numbers and dates compare by value, strings use locale-aware natural ordering, and missing values stay last. sort(a, b) supplies an ascending comparator.

header-key / header receive sorting, sortIndex and toggleSort(multi?); custom headers keep the surrounding cell and aria-sort.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
Entry F
Active
53

Unsorted

<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, Text, Stack, type DataTableSort } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const sorting = ref<DataTableSort[]>([])
</script>

<template>
  <Stack class="w-full">
    <DataTable
      v-model:sorting="sorting"
      :rows="rows.slice(0, 6)"
      :columns="columns"
      row-key="id"
      multi-sort
      label="Entries"
    />
    <Text size="sm" tone="muted">
      {{
        sorting.length
          ? sorting
              .map(
                sort =>
                  `${columns.find(column => column.key === sort.key)?.label ?? sort.key}: ${sort.desc ? 'descending' : 'ascending'}`,
              )
              .join(' · ')
          : 'Unsorted'
      }}
    </Text>
  </Stack>
</template>
vue

Global filtering

v-model:filter matches a case-insensitive substring across participating columns. filterable: false excludes a column from global filtering; filter(row, query) customizes its matcher. Hidden columns still participate. A match in any column includes the row.

Use SearchInput in toolbar. Debouncing, requests and stale-response handling belong to the caller.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, SearchInput } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const filter = ref('')
</script>

<template>
  <DataTable
    v-model:filter="filter"
    :rows="rows"
    :columns="columns"
    row-key="id"
    pagination
    :page-size="5"
    label="Entries"
  >
    <template #toolbar>
      <SearchInput
        size="sm"
        v-model="filter"
        placeholder="Filter names"
        aria-label="Filter names"
        class="w-64 max-w-full"
      />
    </template>
  </DataTable>
</template>
vue

Column filters

v-model:columnFilters stores { key, value }[]. Filters combine with AND and also respect global filtering. filterMode supports contains, equals, in (an array) and range ([min, max], with either bound nullable). Numbers, booleans and dates retain their types. filterValue(row, value) provides custom matching.

Each header slot receives filterValue and setFilter(value). Passing null, undefined, an empty string or an empty array removes the filter. Controls are optional; this example uses Select.

Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, Select, type DataTableFilter } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns, statusLabels } = tableDemo('en')
  const filters = ref<DataTableFilter[]>([])
  const options = Object.entries(statusLabels).map(([value, label]) => ({ value, label }))
  const filteredColumns = columns.map(column =>
    column.key === 'status' ? { ...column, filterMode: 'equals' as const } : column,
  )
</script>

<template>
  <DataTable
    v-model:column-filters="filters"
    :rows="rows"
    :columns="filteredColumns"
    row-key="id"
    pagination
    :page-size="5"
    label="Entries"
  >
    <template #header-status="{ column, filterValue, setFilter }">
      <Select
        :model-value="filterValue === undefined ? undefined : String(filterValue)"
        :options="options"
        :placeholder="column.label"
        :aria-label="column.label"
        clearable
        size="sm"
        class="w-36 py-1"
        @update:model-value="setFilter"
      />
    </template>
  </DataTable>
</template>
vue

Multiple selection

selectable adds Checkbox controls; a predicate disables individual rows. v-model:selected stores keys, distinguishing numbers from strings. Page changes, filtering and remote-result replacement preserve selected keys. Removing stale keys after deletion is the caller’s decision.

The header affects eligible rows on the current page. selectAll="filtered" instead affects all filtered rows currently loaded. Neither mode selects unloaded remote records. rowLabel gives controls readable names.

Selected:1

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DataTable, Inline, Text, type DataTableKey } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const selected = ref<DataTableKey[]>([1])
</script>

<template>
  <DataTable
    v-model:selected="selected"
    :rows="rows"
    :columns="columns"
    row-key="id"
    row-label="name"
    :selectable="row => row.status !== 'archived'"
    pagination
    :page-size="5"
    label="Entries"
  >
    <template #toolbar>
      <Inline align="center" justify="between">
        <Text size="sm" tone="muted">Selected:{{ selected.length }}</Text>
        <Button
          variant="ghost"
          tone="neutral"
          size="sm"
          :disabled="!selected.length"
          @click="selected = []"
        >
          Clear selection
        </Button>
      </Inline>
    </template>
  </DataTable>
</template>
vue

Single selection

selectionMode="single" uses radio controls and replaces the previous selection. selected remains a key array containing at most one key; the header has no select-all control.

Select rowStatus
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, type DataTableKey } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const selected = ref<DataTableKey[]>([2])
</script>

<template>
  <DataTable
    v-model:selected="selected"
    :rows="rows.slice(0, 4)"
    :columns="columns"
    row-key="id"
    row-label="name"
    selectable
    selection-mode="single"
    label="Entries"
  />
</template>
vue

Pagination

pagination enables Pagination. page starts at 1 and pageSize defaults to 10. Sorting, global/column filtering, grouping and page-size changes reset the page; :auto-reset-page="false" retains it. Known totals clamp an out-of-range page after data shrinks.

The default footer contains only page controls. Compose Pagination in footer for optional totals, page-size selection or a jump input.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, Pagination } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const page = ref(1)
  const pageSize = ref(5)
</script>

<template>
  <DataTable
    v-model:page="page"
    v-model:page-size="pageSize"
    :rows="rows"
    :columns="columns"
    row-key="id"
    pagination
    label="Entries"
  >
    <template #footer="{ total, rows: visible }">
      <Pagination
        v-model="page"
        v-model:page-size="pageSize"
        :total="total"
        :item-count="visible.length"
        :page-size-options="[5, 10, 20]"
        show-info
        show-jump
        align="between"
      />
    </template>
  </DataTable>
</template>
vue

Remote data

manual bypasses local filtering, sorting, grouping and pagination together. rows is the current response; total supplies the remote total. Controlled models can initialize from a URL or store.

change emits { page, pageSize, sorting, filter, columnFilters, grouping } once after changes in the same update cycle settle. It does not emit on mount. The caller makes the first request and handles cancellation and stale responses.

loading preserves existing rows, blocks their interactions and uses LoadingOverlay. Temporarily empty results do not clamp the page while loading.

Page 1 · Returned 0 / 0

Status
Loading
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, SearchInput, Text, Tag, Inline } from '@hina-ui/vue'
  import { useRemoteTableDemo } from '../../data-table'
  const { rows, columns, statusLabels, total, loading, lastQuery, load } = useRemoteTableDemo('en')
  const filter = ref('')
</script>

<template>
  <DataTable
    v-model:filter="filter"
    :rows="rows"
    :columns="columns"
    :total="total"
    :loading="loading"
    row-key="id"
    manual
    pagination
    :page-size="5"
    label="Entries"
    @change="load"
  >
    <template #toolbar>
      <Inline justify="between">
        <SearchInput
          size="sm"
          v-model="filter"
          placeholder="Search names"
          aria-label="Search names"
          class="w-64 max-w-full"
        />
        <Text size="sm" tone="muted">
          Page {{ lastQuery.page }} · Returned {{ rows.length }} / {{ total }}
        </Text>
      </Inline>
    </template>
    <template #cell-status="{ row }">
      <Tag :tone="row.status === 'active' ? 'success' : 'neutral'">
        {{ statusLabels[row.status] }}
      </Tag>
    </template>
  </DataTable>
</template>
vue

Unknown totals

Omit total in manual mode to show previous/next controls. hasNextPage explicitly controls the next button; if omitted, a full response page implies another page may exist.

Status
Loading
<script setup lang="ts">
  import { computed } from 'vue'
  import { DataTable } from '@hina-ui/vue'
  import { useRemoteTableDemo } from '../../data-table'
  const { rows, columns, loading, total, lastQuery, load } = useRemoteTableDemo('en')
  const hasNextPage = computed(() => lastQuery.value.page * lastQuery.value.pageSize < total.value)
</script>

<template>
  <DataTable
    :rows="rows"
    :columns="columns"
    row-key="id"
    manual
    pagination
    :page-size="5"
    :has-next-page="hasNextPage"
    :loading="loading"
    label="Entries"
    @change="load"
  />
</template>
vue

Column visibility

v-model:hiddenColumns stores hidden column keys. Visibility changes preserve sorting and filters. No visibility controls are generated by default.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Checkbox, DataTable } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const showCount = ref(true)
  const hiddenColumns = computed(() => (showCount.value ? [] : ['count']))
</script>

<template>
  <DataTable
    :rows="rows.slice(0, 4)"
    :columns="columns"
    :hidden-columns="hiddenColumns"
    row-key="id"
    label="Entries"
  >
    <template #toolbar><Checkbox v-model="showCount">Show count column</Checkbox></template>
  </DataTable>
</template>
vue

Widths, pinning and ordering

Columns accept width, minWidth, maxWidth and logical pin: 'start' | 'end'. truncate keeps default text on one line and shows Tooltip only when it overflows. Custom cell content owns its own truncation.

resizable makes header boundaries draggable, with a guide extending through the table. The default resizeMode="fit" exchanges width with the adjacent column and preserves the current total width. Normal columns resize from their end edge; end-pinned columns resize from their inner start edge. RTL mirrors these directions. The last unpinned column has no outer handle. Boundaries are unavailable when the adjacent column is non-resizable or the pair has no room to resize. resizeMode="expand" changes only the current column. Growing may overflow the container and enable horizontal scrolling; shrinking consumes that overflow and stops when the table reaches the container width, without introducing an empty area. To narrow a column in an already fitted table and give its space to its neighbor, use fit. Both modes respect column bounds.

Resize grips stay within the visible portion of their own header, separate from pinned-region dividers. Partially covered headers retain a usable grip; fully hidden grips are excluded from hit testing and keyboard navigation. Hover shows the affected column names through Tooltip; dragging displays each column’s current pixel width. A shared fit handle names both adjacent columns; expand names only the current column. Interactive resizing of pinned columns leaves at least 48px visible for center columns.

Focus a boundary to resize with Left/Right by one pixel, Shift by ten, or Home/End to the available limits. Escape cancels the current drag. v-model:columnWidths stores manual pixel widths. fit records only the two adjusted columns, leaving unspecified columns free to share remaining container space. expand also records the other rendered widths so those columns stay unchanged. Pressing and releasing without dragging does not write widths; Escape restores the previous settings. Clear the model to restore automatic allocation. Use numeric bounds for resizing; static columns also accept CSS lengths.

reorderColumns makes leaf headers draggable. A column preview and insertion line show the drop position. Clicking still sorts; dragging does not trigger sorting. Escape cancels a drag. Focus a header and use Alt + Left/Right to reorder with the keyboard. v-model:columnOrder stores keys. Reordering stays within the same pinned region; resizable: false and reorderable: false disable the respective operation. layout="fixed", resizing, truncation or virtualization constrain the table layout.

The example uses Select to switch resize modes and Button to clear width and order overrides, restoring the initial layout.

Resize mode

ID
1
Entry A — A longer label that can be truncated
Active
18
2
Entry B — A longer label that can be truncated
Draft
55
3
Entry C — A longer label that can be truncated
Active
92
4
Entry D — A longer label that can be truncated
Active
129
<script setup lang="ts">
  import { ref } from 'vue'
  import {
    DataTable,
    Stack,
    Inline,
    Text,
    Select,
    Button,
    type DataTableColumn,
  } from '@hina-ui/vue'
  import { tableDemo, type TableDemoRow } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const mode = ref<'fit' | 'expand'>('fit')
  const modes = [
    { value: 'fit', label: 'Keep table width' },
    { value: 'expand', label: 'Resize current column' },
  ]
  const order = ref<string[]>([])
  const widths = ref<Record<string, number>>({})
  const sizedColumns: DataTableColumn<TableDemoRow>[] = [
    { key: 'id', label: 'ID', width: 72, pin: 'start', reorderable: false },
    { ...columns[0]!, width: 220, minWidth: 120, maxWidth: 360, truncate: true },
    { ...columns[1]!, width: 180 },
    { ...columns[2]!, width: 140, pin: 'end', reorderable: false },
  ]
  const longRows = rows
    .slice(0, 4)
    .map(row => ({ ...row, name: `${row.name} — A longer label that can be truncated` }))
</script>

<template>
  <Stack gap="sm" class="w-full">
    <Inline justify="between">
      <Inline gap="sm">
        <Text size="sm" tone="muted">Resize mode</Text>
        <Select v-model="mode" :options="modes" size="sm" class="w-44" aria-label="Resize mode" />
      </Inline>
      <Button
        size="sm"
        variant="ghost"
        tone="neutral"
        :disabled="!order.length && !Object.keys(widths).length"
        @click="
          () => {
            order = []
            widths = {}
          }
        "
      >
        Reset columns
      </Button>
    </Inline>
    <DataTable
      v-model:column-order="order"
      v-model:column-widths="widths"
      :rows="longRows"
      :columns="sizedColumns"
      row-key="id"
      resizable
      :resize-mode="mode"
      reorder-columns
      label="Entries"
    />
  </Stack>
</template>
vue

Grouped headers and summaries

Nested column children create multilevel headers. Parent columns supply labels and header slots; leaf columns supply data, sorting and layout. Hidden or reordered leaves update spans automatically.

aggregate accepts sum, min, max, mean, count, uniqueCount or a function. footer: true displays that aggregate; a string or (rows) => text supplies custom output. Summaries use all filtered loaded rows, including collapsed rows. footer-key replaces one summary cell; summary replaces the contents of <tfoot> and should return table rows.

Details
Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
Total310
<script setup lang="ts">
  import { DataTable, type DataTableColumn } from '@hina-ui/vue'
  import { tableDemo, type TableDemoRow } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const groupedColumns: DataTableColumn<TableDemoRow>[] = [
    { key: 'details', label: 'Details', children: [columns[0]!, columns[1]!] },
    { ...columns[2]!, aggregate: 'sum', footer: true },
  ]
</script>

<template>
  <DataTable :rows="rows.slice(0, 5)" :columns="groupedColumns" row-key="id" label="Entries">
    <template #footer-name>Total</template>
  </DataTable>
</template>
vue

Row expansion

expandable adds row toggles and can be a predicate. v-model:expanded stores row keys. expansion receives the row context and renders below it across all columns. Expanding does not consume another pagination slot.

ExpandStatus
Entry A
Active
18
ID
1
Status
Active
Count
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
<script setup lang="ts">
  import { ref } from 'vue'
  import {
    DescriptionList,
    DescriptionTerm,
    DescriptionDetails,
    DataTable,
    type DataTableKey,
  } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns, statusLabels } = tableDemo('en')
  const expanded = ref<DataTableKey[]>([1])
</script>

<template>
  <DataTable
    v-model:expanded="expanded"
    :rows="rows.slice(0, 4)"
    :columns="columns"
    row-key="id"
    row-label="name"
    expandable
    label="Entries"
  >
    <template #expansion="{ row }">
      <DescriptionList class="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
        <DescriptionTerm class="text-muted">ID</DescriptionTerm>
        <DescriptionDetails>{{ row.id }}</DescriptionDetails>
        <DescriptionTerm class="text-muted">Status</DescriptionTerm>
        <DescriptionDetails>{{ statusLabels[row.status] }}</DescriptionDetails>
        <DescriptionTerm class="text-muted">Count</DescriptionTerm>
        <DescriptionDetails>{{ row.count }}</DescriptionDetails>
      </DescriptionList>
    </template>
  </DataTable>
</template>
vue

Tree data

getChildren(row) supplies nested rows; keys must be unique throughout the tree. expanded controls visibility and depth adds indentation. Pagination counts root rows; filtering retains parents of matching descendants.

Selection includes descendants by default and parents show a mixed state when only some children are selected. :select-children="false" makes selection independent. Single selection always selects one row.

ExpandStatus
Entry A
Active
18
Entry D
Active
129
Entry E
Archived
16
Entry B
Draft
55
Entry C
Active
92
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable, type DataTableKey } from '@hina-ui/vue'
  import { treeTableDemo } from '../../data-table'
  const { rows, columns, getChildren } = treeTableDemo('en')
  const selected = ref<DataTableKey[]>([4])
  const expanded = ref<DataTableKey[]>([1])
</script>

<template>
  <DataTable
    v-model:selected="selected"
    v-model:expanded="expanded"
    :rows="rows"
    :columns="columns"
    :get-children="getChildren"
    row-key="id"
    row-label="name"
    selectable
    label="Entries"
  />
</template>
vue

Grouping and aggregation

v-model:grouping stores column keys in grouping order. v-model:expandedGroups controls generated group keys, independently of data-row expanded keys. Multiple columns produce nested groups. Pagination counts outer groups.

Default group rows display the grouping value, count and column aggregates. group receives the group key, column, value, rows, depth, expanded, toggleExpanded() and aggregate(columnKey). Group rows cannot be selected or edited. In manual mode the data source owns grouping; getChildren can represent its hierarchy.

Status
—382
Entry A
Active
18
Entry C
Active
92
Entry D
Active
129
Entry F
Active
53
Entry G
Active
90
—182
—16
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const grouping = ref(['status'])
  const expandedGroups = ref(['status:active'])
  const groupedColumns = columns.map(column =>
    column.key === 'count' ? { ...column, aggregate: 'sum' as const } : column,
  )
</script>

<template>
  <DataTable
    v-model:grouping="grouping"
    v-model:expanded-groups="expandedGroups"
    :rows="rows.slice(0, 8)"
    :columns="groupedColumns"
    row-key="id"
    label="Entries"
  ></DataTable>
</template>
vue

Cell and row editing

Set column editable and editMode="cell" | "row". Double-click or focus a cell and press Enter to edit; row mode supplies an edit button. editor-key / editor replace the input and receive value, updateValue, pending, error, commit and cancel. Save/cancel controls and error feedback remain component-owned. The example uses Select for one editor.

Drafts do not mutate input rows. parse converts draft values; validate returns an error string or undefined, synchronously or asynchronously. onSave(edit) may return a promise. While pending, duplicate saves are blocked. Rejection keeps the draft open, displays the error and emits editError; it does not create an unhandled rejection.

Success emits edit with { key, row, column?, values }; values is keyed by column key. The caller applies changes, including mapping accessor-backed columns. onSave runs once before edit; do not send the same request from both. Default inputs support Enter to save and Escape to cancel.

In row mode, save errors appear once below the row. Column validation errors appear at the corresponding field and are exposed through its error slot parameter. Editors use the bare variant of InputGroup; Input, Select, and other compatible controls in custom slots inherit its embedded appearance and size.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
<script setup lang="ts">
  import { Checkbox, DataTable, Select, Inline } from '@hina-ui/vue'
  import { useEditableTableDemo } from '../../data-table'
  const { rows, columns, statusLabels, mode, fail, save } = useEditableTableDemo('en')
  const modes = [
    { value: 'cell', label: 'Cell' },
    { value: 'row', label: 'Row' },
  ]
  const options = Object.entries(statusLabels).map(([value, label]) => ({ value, label }))
</script>

<template>
  <DataTable
    :rows="rows"
    :columns="columns"
    row-key="id"
    row-label="name"
    :edit-mode="mode"
    :on-save="save"
    layout="fixed"
    label="Entries"
  >
    <template #toolbar>
      <Inline align="center" wrap>
        <Select size="sm" v-model="mode" :options="modes" aria-label="Edit mode" class="w-32" />
        <Checkbox v-model="fail">Simulate save failure</Checkbox>
      </Inline>
    </template>
    <template #editor-status="{ value, updateValue, pending }">
      <Select
        :model-value="value as string"
        :options="options"
        :disabled="pending"
        aria-label="Status"
        size="sm"
        @update:model-value="updateValue"
      />
    </template>
  </DataTable>
</template>
vue

Row reordering

reorderable adds drag handles for mouse and touch, with Up/Down keyboard support. v-model:rows receives a reordered root array; source arrays are never mutated. rowReorder includes { row, target, parent?, from, to, rows }. For nested rows, apply the sibling array to the supplied parent; reparenting is not performed.

Dragging is disabled while sorting, filtering or grouping changes the displayed order. A predicate can exclude individual rows. Reordering operates on loaded siblings; persistence belongs to the caller.

Move rowNameStatusCount
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { DataTable } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const source = tableDemo('en')
  const rows = ref(source.rows.slice(0, 5))
  const columns = source.columns.map(column => ({ ...column, sortable: false }))
</script>

<template>
  <DataTable
    v-model:rows="rows"
    :columns="columns"
    row-key="id"
    row-label="name"
    reorderable
    label="Entries"
  />
</template>
vue

Scrolling

Horizontal overflow stays inside ScrollArea. maxHeight limits the scroll area and stickyHeader pins headers. class applies to the entire DataTable; tableClass applies to its scroll area. The instance exposes viewport and the native table element.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
Entry F
Active
53
Entry G
Active
90
Entry H
Draft
127
Entry I
Active
14
Entry J
Archived
51
Entry K
Draft
88
Entry L
Active
125
Entry M
Active
12
Entry N
Draft
49
Entry O
Archived
86
Entry P
Active
123
Entry Q
Draft
10
Entry R
Active
47
Entry S
Active
84
Entry T
Archived
121
Entry U
Active
8
Entry V
Active
45
Entry W
Draft
82
Entry X
Active
119
<script setup lang="ts">
  import { DataTable } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const wideColumns = columns.map(column => ({
    ...column,
    width: column.key === 'name' ? 320 : 200,
  }))
</script>

<template>
  <DataTable
    :rows="rows"
    :columns="wideColumns"
    row-key="id"
    sticky-header
    :max-height="240"
    label="Entries"
  />
</template>
vue

Fill and sticky summaries

height sets the total component height, including toolbar and pagination. fill fills a parent with a definite height. The table scrolls in the remaining space. stickyFooter pins summary cells.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
Entry F
Active
53
Entry G
Active
90
Entry H
Draft
127
Entry I
Active
14
Entry J
Archived
51
Entry K
Draft
88
Entry L
Active
125
Entry M
Active
12
Entry N
Draft
49
Entry O
Archived
86
Total1644
<script setup lang="ts">
  import { Stack, DataTable } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const summaryColumns = columns.map(column =>
    column.key === 'count' ? { ...column, aggregate: 'sum' as const, footer: true } : column,
  )
</script>

<template>
  <Stack class="h-80 w-full">
    <DataTable
      :rows="rows"
      :columns="summaryColumns"
      row-key="id"
      fill
      sticky-header
      sticky-footer
      pagination
      :page-size="15"
      label="Entries"
    >
      <template #footer-name>Total</template>
    </DataTable>
  </Stack>
</template>
vue

CSV export

api.toCsv(options) returns CSV; api.exportCsv(options) downloads it. scope accepts page, filtered (default), selected or all. Only loaded records are available, including in remote mode. Selected keys without a loaded row cannot be exported.

Export follows visible column order and excludes exportable: false columns. columns narrows the column keys. exportValue overrides the raw value; formatted otherwise opts into display formatting. Strings are quoted and escaped, formula-like strings are protected, and UTF-8 BOM is enabled by default. delimiter, bom and filename are configurable.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Entry D
Active
129
Entry E
Archived
16
<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DataTable, Inline, type DataTableKey } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const selected = ref<DataTableKey[]>([1, 2])
</script>

<template>
  <DataTable
    v-model:selected="selected"
    :rows="rows.slice(0, 5)"
    :columns="columns"
    row-key="id"
    row-label="name"
    selectable
    label="Entries"
  >
    <template #toolbar="{ api }">
      <Inline wrap>
        <Button
          size="sm"
          variant="soft"
          tone="neutral"
          @click="api.exportCsv({ formatted: true, filename: 'entries.csv' })"
        >
          Export CSV
        </Button>
        <Button
          size="sm"
          variant="ghost"
          tone="neutral"
          :disabled="!selected.length"
          @click="api.exportCsv({ scope: 'selected', formatted: true, filename: 'selected.csv' })"
        >
          Export selected
        </Button>
      </Inline>
    </template>
  </DataTable>
</template>
vue

Empty and loading states

emptyText replaces the default message; empty accepts custom content such as Empty. The loading slot customizes LoadingOverlay content and should retain a status announcement.

Status
Entry A
Active
18
Entry B
Draft
55
Entry C
Active
92
Status

No entries

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, DataTable, Empty, Stack } from '@hina-ui/vue'
  import { tableDemo } from '../../data-table'
  const { rows, columns } = tableDemo('en')
  const loading = ref(false)
</script>

<template>
  <Stack class="w-full">
    <Button size="sm" variant="soft" tone="neutral" class="self-start" @click="loading = !loading">
      Toggle loading
    </Button>
    <DataTable
      :rows="rows.slice(0, 3)"
      :columns="columns"
      row-key="id"
      :loading="loading"
      label="Entries"
    />
    <DataTable :rows="[]" :columns="columns" row-key="id" label="Entries">
      <template #empty><Empty size="sm" title="No entries" :icon="false" /></template>
    </DataTable>
  </Stack>
</template>
vue

Virtual scrolling

virtualize renders only visible rows plus overscan. { estimateSize, overscan } configures the initial row estimate and buffer; actual row and expansion heights are measured. Combine it with pinned columns, selection, expansion and grouping. Provide height, maxHeight or fill; otherwise the viewport defaults to 400px.

The complete data pipeline still operates on supplied rows. Virtualization reduces mounted DOM; it does not fetch data. api.scrollToRow(key) scrolls to a displayed row, including one outside the current rendered window. Expand ancestors or change the page first for hidden rows.

ExpandStatus
Entry 1
Active
0
Entry 2
Active
1
Entry 3
Active
2
Entry 4
Active
3
Entry 5
Active
4
Entry 6
Active
5
Entry 7
Active
6
Entry 8
Active
7
Entry 9
Active
8
Entry 10
Active
9
Entry 11
Active
10
Entry 12
Active
11
Entry 13
Active
12
Entry 14
Active
13
Entry 15
Active
14
Entry 16
Active
15
<script setup lang="ts">
  import { ref } from 'vue'
  import { Stack, Button, DataTable, type DataTableApi } from '@hina-ui/vue'
  import { tableDemo, type TableDemoRow } from '../../data-table'
  const { columns } = tableDemo('en')
  const table = ref<{ api: DataTableApi<TableDemoRow> }>()
  const rows: TableDemoRow[] = Array.from({ length: 10000 }, (_, index) => ({
    id: index + 1,
    name: 'Entry ' + (index + 1),
    status: 'active',
    count: index,
  }))
</script>

<template>
  <DataTable
    ref="table"
    :rows="rows"
    :columns="columns"
    row-key="id"
    row-label="name"
    :virtualize="{ estimateSize: 44, overscan: 6 }"
    :height="320"
    sticky-header
    expandable
    label="Entries"
  >
    <template #toolbar>
      <Button
        size="sm"
        variant="soft"
        tone="neutral"
        class="self-start"
        @click="table?.api.scrollToRow(5000)"
      >
        Go to row 5,000
      </Button>
    </template>
    <template #expansion="{ row }">
      <Stack class="flex h-32 items-center text-muted">{{ row.name }} · Expanded content</Stack>
    </template>
  </DataTable>
</template>
vue

Accessibility

The component retains native table semantics, multilevel header scopes and row positions under virtualization. Use caption or label for its accessible name. Sorting, selection, resizing, reordering and editing have keyboard entry points. Focus returns to the editing trigger after save/cancel. Custom editors and custom summary rows should retain the corresponding semantics.

API

Props

Prop
Type
Default
Description
rows
T[]
Required
Supplied rows
columns
DataTableColumn<T>[]
Required
Column definitions
rowKey
DataTableKeyField<T> | ((row: T) => DataTableKey)
Required
Globally unique stable key
rowLabel
keyof T | ((row: T) => string)
Row key
Accessible row label
selectable
boolean | ((row: T) => boolean)
false
Selection eligibility
selectionMode
'single' | 'multiple'
multiple
Selection mode
selectAll
'page' | 'filtered'
page
Header selection scope
selectChildren
boolean
true
Cascade selection to descendants
expandable
boolean | ((row: T) => boolean)
false
Detail expansion eligibility
getChildren
(row: T) => T[] | undefined
—
Tree children
pagination
boolean
false
Enable page controls
manual
boolean
false
External query processing
total
number
—
Known remote total
hasNextPage
boolean
—
Next-page availability without total
autoResetPage
boolean
true
Reset page after query changes
multiSort
boolean
false
Append sorting with Shift
resizable
boolean
false
Column resize handles
resizeMode
'fit' | 'expand'
fit
Exchange width with the adjacent column, or resize only the current column
reorderColumns
boolean
false
Drag headers to reorder
reorderable
boolean | ((row: T) => boolean)
false
Row drag handles
virtualize
boolean | { estimateSize?: number; overscan?: number }
false
Windowed rendering; estimate 44, overscan 6
editMode
'cell' | 'row'
—
Editing mode
onSave
(edit: DataTableEdit<T>) => void | Promise<void>
—
Awaited save callback
loading
boolean
false
Block stale rows while loading
disabled
boolean
false
Disable built-in interactions
rowClickable
boolean
false
Enable row activation
rowClass
(row: T) => string | undefined
—
Per-row classes
variant
'primary' | 'secondary'
primary
Appearance
hover
boolean
true
Row hover feedback
stickyHeader
boolean
false
Pin headers
stickyFooter
boolean
false
Pin summary cells
height
number | string
—
Total component height
maxHeight
number | string
—
Scroll-area maximum height
fill
boolean
false
Fill a definite-height parent
layout
'auto' | 'fixed'
auto
Table layout; constrained features use fixed
caption
string
—
Visible caption
label
string
—
Accessible table name
emptyText
string
Locale
Empty message
class
string
—
Root classes
tableClass
string
—
Scroll-area classes

Columns

Property
Type
Description
key
string
Unique column identifier
label
string
Header label
children
DataTableColumn<T>[]
Nested headers
field
keyof T
Source field; defaults to key
accessor
(row: T) => unknown
Custom source value
sortable
boolean
Enable sort control
sort
(a: T, b: T) => number
Ascending comparator
filterable
boolean
Participate in global filtering; default true
filter
(row: T, query: string) => boolean
Global matcher
filterMode
'contains' | 'equals' | 'in' | 'range'
Column matcher; default contains
filterValue
(row: T, value: unknown) => boolean
Custom column matcher
format
(value: unknown, row: T) => string | number
Display formatting
align
'start' | 'center' | 'end'
Logical cell alignment
width / minWidth / maxWidth
number | string
Column dimensions
pin
'start' | 'end'
Pinned region
truncate
boolean
Single line with overflow tooltip
resizable / reorderable
boolean
Disable a handle with false
aggregate
DataTableAggregate | ((rows: T[]) => unknown)
Group and footer aggregation
footer
boolean | string | ((rows: T[]) => string | number)
Summary output
editable
boolean | ((row: T) => boolean)
Editing eligibility
parse
(value: unknown, row: T) => unknown
Convert draft value
validate
(value: unknown, row: T) => string | undefined | Promise<string | undefined>
Return validation error
exportable
boolean
Exclude from CSV with false
exportValue
(row: T) => unknown
Export value override
headerClass
string
Header classes
cellClass
string | ((row: T) => string | undefined)
Cell classes

Models

Binding
Type
Default
v-model:page
number
1
v-model:pageSize
number
10
v-model:sorting
DataTableSort[]
[]
v-model:filter
string
''
v-model:columnFilters
DataTableFilter[]
[]
v-model:grouping
string[]
[]
v-model:selected
DataTableKey[]
[]
v-model:expanded
DataTableKey[]
[]
v-model:expandedGroups
string[]
[]
v-model:hiddenColumns
string[]
[]
v-model:columnOrder
string[]
[]
v-model:columnWidths
Record<string, number>
{}

v-model:rows is optional for root-row reordering; otherwise pass rows normally.

Slots

Slot
Props
Description
cell-key / cell
DataTableCellContext<T>
Data cells
header-key / header
DataTableHeaderContext<T>
Header content
editor-key / editor
DataTableEditorContext<T>
Editing control
expansion
DataTableRowContext<T>
Expanded detail
group
DataTableGroupContext<T>
Group row
footer-key
{ column, rows: T[] }
Summary cell
summary
DataTableState<T>
Contents of tfoot
toolbar / footer
DataTableState<T>
Toolbar and pagination footer
empty / loading
—
Status content

DataTableState<T> contains the query, total, displayed original rows, selected, expanded, visibleColumns and api.

Events

EventArguments
Description
changeDataTableQuery
Query update
rowClick(row: T, event: MouseEvent | KeyboardEvent)
Row activation
rowContextmenu(row: T, event: MouseEvent)
Context menu event
update:rowsT[]
Reordered root array
rowReorderDataTableReorder<T>
Root or sibling reorder
editDataTableEdit<T>
Successful edit
editError(error: unknown, edit: DataTableEdit<T>)
Save or validation exception

Instance

The instance exposes element: HTMLTableElement | undefined, viewport: HTMLElement | undefined, state: DataTableState<T> and api: DataTableApi<T>. The same API is available in toolbar/footer slot props.

MethodReturns
Description
getRows(scope?)T[]
page / filtered / selected / all; loaded data only
toggleSelected(key, value?)void
Select a loaded row
toggleExpanded(key, value?)void
Toggle row expansion
setFilter(column, value)void
Set a column filter
setColumnHidden(column, hidden)void
Change visibility
setColumnWidth(column, width)void
Set a pixel width within bounds
moveColumn(column, target)void
Move within a pinned region
moveRow(key, target)void
Reorder eligible siblings
startEdit(key, column?)void
Start cell or row editing
cancelEdit()void
Discard the draft
commitEdit()Promise<void>
Validate and save
scrollToRow(key)void
Scroll to a row in the displayed set
toCsv(options?)string
Return CSV
exportCsv(options?)void
Download CSV
interface DataTableExportOptions {
  scope?: 'page' | 'filtered' | 'selected' | 'all'
  columns?: string[]
  formatted?: boolean
  delimiter?: ',' | ';' | '\t'
  bom?: boolean
  filename?: string
}
ts