| 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>
Usage
import { DataTable, type DataTableColumn } from '@hina-ui/vue'
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>
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>
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>
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>
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>
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 row | Status | ||
|---|---|---|---|
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>
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>
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>
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>
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>
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>
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 |
| Total | 310 | |
<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>
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.
| Expand | Status | ||
|---|---|---|---|
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 {
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>
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.
| Expand | Status | |||
|---|---|---|---|---|
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>
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>
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>
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 row | Name | Status | Count |
|---|---|---|---|
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>
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>
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 |
| Total | 1644 |
<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>
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>
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>
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.
| Expand | Status | ||
|---|---|---|---|
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>
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
| Event | Arguments | Description |
|---|---|---|
change | DataTableQuery | Query update |
rowClick | (row: T, event: MouseEvent | KeyboardEvent) | Row activation |
rowContextmenu | (row: T, event: MouseEvent) | Context menu event |
update:rows | T[] | Reordered root array |
rowReorder | DataTableReorder<T> | Root or sibling reorder |
edit | DataTableEdit<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.
| Method | Returns | 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
}