Table
A table built on TanStack Table, with a declarative column API - sorting, row selection, pagination, filtering, expansion, column visibility, virtual scroll, and pinned columns out of the box.
Usage
Pass data and declare columns with <SColumn> children - no column config
objects required for the common case:
Name | Email | Role | |
|---|---|---|---|
| Alice Johnson | [email protected] | Admin | |
| Bob Smith | [email protected] | Member | |
| Cara Lee | [email protected] | Member |
<script setup lang="ts">
interface User { name: string, email: string, role: string }
const users = ref<User[]>([
{ name: 'Alice Johnson', email: '[email protected]', role: 'Admin' },
{ name: 'Bob Smith', email: '[email protected]', role: 'Member' },
])
const rowSelection = ref({})
const globalFilter = ref('')
const getUserId = (user: User) => user.email
</script>
<template>
<SInput v-model="globalFilter" placeholder="Search users..." />
<STable
v-model:row-selection="rowSelection"
v-model:global-filter="globalFilter"
:data="users"
:get-row-id="getUserId"
selectable
:page-size="3"
>
<SColumn field="name" header="Name" filterable sortable />
<SColumn field="email" header="Email" />
<SColumn field="role" header="Role" />
</STable>
</template>
Columns are not sortable by default. Add sortable to an <SColumn> to
enable sorting; an adjacent icon shows its current state (unsorted, ascending,
or descending). Click a sortable header, or focus it and press
Enter/Space, to cycle through those states. Shift-click
a second sortable header to add it as a secondary sort without clearing the
first. filterable adds a funnel button beside that column's header; it opens
a popover with a live text filter and a Clear action. selectable adds a leading checkbox column wired to
v-model:row-selection.
Use select-on-row-click alongside selectable to toggle selection by
clicking anywhere on a row. Clicks on controls inside cells (such as links,
buttons, and checkboxes) keep their normal behavior. row-hover adds a subtle
theme-aware background while the pointer is over a body row:
Name | Email | Role | |
|---|---|---|---|
| Alice Johnson | [email protected] | Admin | |
| Bob Smith | [email protected] | Member | |
| Cara Lee | [email protected] | Member |
Selected: none
<STable
v-model:row-selection="rowSelection"
:data="users"
:get-row-id="user => user.email"
selectable
select-on-row-click
row-hover
>
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
</STable>
Selection and expansion use TanStack's positional row ids by default. For
data that can be sorted, filtered, refreshed, or paginated by a server, provide
get-row-id so controlled state follows the record rather than its current
array position:
<STable
v-model:row-selection="rowSelection"
:data="users"
:get-row-id="getUserId"
selectable
>
<SColumn field="name" header="Name" />
</STable>
STable doesn't render a global search input itself - wire one up yourself
and bind it to v-model:global-filter, same compositional approach as the
rest of the library.
Grouped headers
Wrap columns in <SColumnGroup> to produce a multi-row header with the
correct colspan/rowspan - nest as deeply as needed:
Name | Details | ||
|---|---|---|---|
Age | City | Department | |
| Alice Johnson | 29 | New York | Engineering |
| Bob Smith | 34 | Austin | Sales |
| Cara Lee | 41 | Seattle | Engineering |
<STable :data="employees">
<SColumn field="name" header="Name" />
<SColumnGroup header="Details">
<SColumn field="age" header="Age" />
<SColumn field="city" header="City" />
<SColumn field="department" header="Department" />
</SColumnGroup>
</STable>
Pagination
Pagination is opt-in: every row renders on one page until you set
page-size. Once set, a Pagination
control (page numbers, Prev/Next, scaling with size) appears automatically
once there's more than one page, and binds to v-model:page-index if you
need to read or control the current page yourself. virtualize is the
better fit for windowing a large dataset instead - see
Virtual scroll below.
Presort
default-sorting seeds the initial sort without needing v-model:sorting -
useful when you want a table to open already sorted but still let the user
change it freely afterward:
<STable :data="users" :default-sorting="[{ id: 'name', desc: false }]">
<SColumn field="name" header="Name" sortable />
</STable>
Unlike sorting, this is read once as a starting value, not on every render -
the same uncontrolled-default pattern as Accordion/Tabs's defaultValue.
Custom cell content
Give an <SColumn> a default slot to customize how each cell renders. It
receives { row, value }, where row is the full row object and value is
that column's own field value:
<SColumn field="role" header="Role">
<template #default="{ value }">
<SBadge :label="value" />
</template>
</SColumn>
Custom header content
<SColumn>/<SColumnGroup> take the same treatment for their header/
footer - a named slot (scoped with { column }, TanStack's own column
API) that replaces the plain string prop when given:
Name | Role |
|---|---|
| Alice Johnson | Admin |
| Bob Smith | Member |
<SColumn field="role">
<template #header>
<span class="inline-flex items-center gap-1">
Role
<STooltip text="Admins can manage billing and members">
<SIcon name="hugeicons:information-circle" class="size-3.5 text-[var(--selaras-resolved-text-muted)]" />
</STooltip>
</span>
</template>
</SColumn>
Row expansion
expandable adds a leading toggle column; give STable an expanded slot
for the detail content, receiving { row } (the row's original data object):
Order | Customer | Total | |
|---|---|---|---|
| 1001 | Alice Johnson | $120.00 | |
| 1002 | Bob Smith | $45.50 |
<STable :data="orders" expandable>
<SColumn field="id" header="Order" />
<SColumn field="customer" header="Customer" />
<SColumn field="total" header="Total" />
<template #expanded="{ row }">
<strong>Items:</strong> {{ row.items.join(', ') }}
</template>
</STable>
Expansion state is uncontrolled by default; bind v-model:expanded if you
need to read or drive it yourself (e.g. to expand a row programmatically).
Tree data
Set tree to render hierarchical rows from each row's subRows array. Parent
rows get an inline expand control in the first visible data column, and nested
rows are indented by depth. For another child property, pass get-sub-rows.
Use stable get-row-id values when the same table may expand, select, or sort
tree rows. Tree mode uses expanded for child visibility, so it cannot be
combined with the detail #expanded slot.
Team | Lead |
|---|---|
Engineering | Arif Rahman |
Design | Nina Patel |
<STable :data="teams" tree :get-row-id="team => team.name">
<SColumn field="name" header="Team" />
<SColumn field="lead" header="Lead" />
</STable>
For a different nested field, return each row's children with
get-sub-rows="row => row.children".
Row click
@row-click/@row-contextmenu fire with the row's original data object
and the native event - useful for navigating to a detail page, for
instance. A row only gets a pointer cursor when @row-click actually has
a listener attached, so a plain, non-interactive table isn't misleadingly
styled as clickable:
Name | Email | Role |
|---|---|---|
| Alice Johnson | [email protected] | Admin |
| Bob Smith | [email protected] | Member |
| Cara Lee | [email protected] | Member |
<STable :data="users" @row-click="(row) => router.push(`/users/${row.id}`)">
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
<SColumn field="role" header="Role" />
</STable>
Per-row styling
row-class/row-style are called with each row's own data, letting you
style a row conditionally - highlighting an overdue order, say:
Order | Customer | Total |
|---|---|---|
| #1001 | Alice Johnson | 42 |
| #1002 | Bob Smith | 128 |
| #1003 | Cara Lee | 76 |
<STable :data="orders" :row-class="(row) => row.overdue ? 'bg-[var(--selaras-resolved-color-danger-subtle)]' : undefined">
<SColumn field="id" header="Order" />
<SColumn field="customer" header="Customer" />
<SColumn field="total" header="Total" />
</STable>
Column visibility toggle
column-toggle renders a "Columns" button above the table that opens a panel
of checkboxes, one per column, letting the user hide/show columns on the fly:
Name | Email | Role |
|---|---|---|
| Alice Johnson | [email protected] | Admin |
| Bob Smith | [email protected] | Member |
<STable :data="users" column-toggle>
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
<SColumn field="role" header="Role" />
</STable>
Bind v-model:column-visibility if you need to read or control which
columns are shown yourself (e.g. to persist the choice).
Frozen columns
Give a column pinned="left" or pinned="right" to keep it fixed at that
edge during horizontal scroll - pair it with scroll-height (below) and
enough columns (or a ui.table min-width override) that the table actually
needs to scroll:
Name | Department | Role | Email | Phone | City | Start date | Actions |
|---|---|---|---|---|---|---|---|
| Employee 1 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 2 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 3 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 4 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 5 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 6 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 7 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 | |
| Employee 8 | Engineering | Software Engineer | [email protected] | +1 555 0100 | San Francisco | 2023-01-15 |
<STable :data="employees" scroll-height="16rem" :ui="{ table: 'min-w-[960px]' }">
<SColumn field="name" header="Name" pinned="left" />
<SColumn field="department" header="Department" />
<SColumn field="role" header="Role" />
<SColumn field="email" header="Email" />
<SColumn field="phone" header="Phone" />
<SColumn field="city" header="City" />
<SColumn field="startDate" header="Start date" />
<SColumn field="actions" header="Actions" sortable="false" pinned="right">
<template #default>
<SButton variant="outline" size="sm">Edit</SButton>
</template>
</SColumn>
</STable>
Sticky offsets are measured from each header's real rendered width after mount and whenever the column list or visibility changes - not on every possible layout shift (a later web-font load, a window resize), which is a deliberate scope cut for this pass.
Virtual scroll
scroll-height caps the table at a fixed CSS height with a sticky header and
vertical scroll. Add virtualize to only render the rows currently in view,
for datasets too large to put in the DOM all at once:
ID | Name |
|---|
<STable :data="tenThousandRows" scroll-height="16rem" virtualize>
<SColumn field="id" header="ID" />
<SColumn field="name" header="Name" />
</STable>
Pass an object instead of true to tune estimateSize/overscan:
<STable :data="rows" scroll-height="16rem" :virtualize="{ estimateSize: 48, overscan: 12 }" />
Omit either field (or pass virtualize as plain true) and it defaults to
estimateSize: 40, overscan: 8.
Virtualizing replaces pagination as the strategy for a large dataset - while
virtualize is set, page-size has no effect and the pagination controls
don't render.
Sizes, grid lines & striped rows
size (sm / md / lg) controls cell padding and text size. gridlines
adds borders around every cell instead of just row dividers, and striped
alternates row backgrounds:
Name | Email | Role |
|---|---|---|
| Alice Johnson | [email protected] | Admin |
| Bob Smith | [email protected] | Member |
| Carol Lee | [email protected] | Member |
Gridline tables keep one rounded outer frame around the scroll viewport, with
lines between columns and rows. The same frame remains around empty and loading
states; no extra table border is added. Use ui.wrapper to customize the frame
and ui.th / ui.td for cell lines.
<STable :data="users" gridlines striped size="sm">
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
<SColumn field="role" header="Role" />
</STable>
Loading
loading overlays the table with a spinner while keeping the existing rows
visible underneath, rather than swapping to an empty state:
Name | Email |
|---|---|
| Alice Johnson | [email protected] |
<STable :data="users" :loading="loading">
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
</STable>
Export
STable exposes an exportCsv(filename?) method via a template ref - it
downloads every row matching the current filters/sort across all pages (not
just the current page), skipping any synthetic selection/expand column:
Name | Email | Role |
|---|---|---|
| Alice Johnson | [email protected] | Admin |
| Bob Smith | [email protected] | Member |
<script setup lang="ts">
const tableRef = ref()
</script>
<template>
<SButton @click="tableRef.exportCsv('users.csv')">
Export CSV
</SButton>
<STable ref="tableRef" :data="users">
<SColumn field="name" header="Name" />
<SColumn field="email" header="Email" />
</STable>
</template>
Server-side / manual mode
By default, STable sorts/filters/paginates data itself, locally, every
time. Set manual-sorting/manual-filtering/manual-pagination when
data is already sorted/filtered/paginated server-side instead - each one
opts that specific concern out of the local row model (TanStack's term for
the actual list of rows it computes after applying sorting/filtering/
pagination), so STable doesn't redundantly (and incorrectly) reprocess a
slice the server already handled. sorting/global-filter/page-index still drive the UI and
still emit their own update:* events the normal way; only the local row
model backing them changes.
manual-pagination also suppresses the built-in pagination UI entirely -
there's no way to derive a real page count from a server-paginated slice
of data, and guessing at one would be misleading. Bring your own
SPagination instead, bound to
whatever your server actually returns, the same way this table already
expects you to bring your own search input for global-filter (see
Usage above) rather than rendering one itself:
<script setup lang="ts">
const sorting = ref([])
const pageIndex = ref(0)
const total = ref(0)
const rows = ref([])
watch([sorting, pageIndex], async () => {
const result = await fetchUsers({ sorting: sorting.value, page: pageIndex.value })
rows.value = result.rows
total.value = result.total
}, { immediate: true })
</script>
<template>
<STable
v-model:sorting="sorting"
v-model:page-index="pageIndex"
:data="rows"
manual-sorting
manual-pagination
>
<SColumn field="name" header="Name" sortable />
<SColumn field="email" header="Email" sortable />
</STable>
<SPagination
:page="pageIndex + 1"
:total="total"
:items-per-page="10"
@update:page="(page) => pageIndex = page - 1"
/>
</template>
Escape hatch: raw column defs
For full TanStack type inference (or features <SColumn> doesn't expose),
pass a columns prop instead of <SColumn> children. Raw column definitions
also start non-sortable; set enableSorting: true on columns that should sort.
STable infers its row type from data, and TableColumnDef<TData> checks
accessor keys and render contexts against that same type.
createTableColumnHelper<TData>() is
auto-imported by the Nuxt module and binds TanStack v9's feature generic to the
fixed feature set used by STable:
<script setup lang="ts">
interface User { id: string, name: string, age: number }
const column = createTableColumnHelper<User>()
const columns = column.columns([
column.accessor('name', { header: 'Name', enableSorting: true }),
column.accessor('age', {
header: 'Age',
enableSorting: true,
cell: context => context.getValue().toFixed(0),
}),
])
</script>
<template>
<STable :data="users" :columns="columns" />
</template>
Known limitation
<SColumn>'s field prop remains a plain string, rather than keyof TData -
this is a genuine Vue limitation (a parent's generic type parameter doesn't
propagate into child components referenced in its template), not something
unique to STable - any declarative-column-as-children table API in Vue has
the identical gap. As a cheap safety net, STable warns in dev mode (not
production) if a column's field doesn't exist as a key on the first row of
data - catching typos without any type-system gymnastics. For actual
compile-time safety, use the columns escape hatch above instead.
The table component itself is generic. Its columns, getRowId, rowClass,
rowStyle, row events, and expanded slot all share the row type inferred
from data.
Accessibility
Sortable headers are keyboard-operable (Enter/Space
toggle the sort, same as a click) and expose aria-sort reflecting the
current state. The row-expansion toggle is a real <button> with an
aria-label of "Expand row"/"Collapse row" that updates with its state.
Each filterable column has a labeled filter button that opens its text filter
in a popover; its Clear action resets that column's filter.
Column-visibility checkboxes are standard SCheckbox elements, and
pagination is a nested SPagination, so
both inherit those components' own accessibility behavior rather than
reimplementing it here.
Custom :ui
SColumn/SColumnGroup render no DOM of their own (see
Escape hatch: raw column defs above) and
have no theme file - every visual slot lives on STable itself. To see
exactly what you'd be overriding - the current default classes for every
slot - here's Table's own theme file:
Props
STable
| Prop | Type | Default |
|---|---|---|
data | TData[] | - |
columns | TableColumnDef<TData>[] | - |
selectable | boolean | false |
selectOnRowClick | boolean | false |
rowHover | boolean | false |
pageSize | number | - (no pagination until set) |
loading | boolean | false |
sorting | SortingState | - |
defaultSorting | SortingState | - |
rowSelection | TableRowSelectionState | - |
globalFilter | string | - |
pageIndex | number | - |
size | 'sm' | 'md' | 'lg' | 'md' |
gridlines | boolean | false |
striped | boolean | false |
expandable | boolean | false |
tree | boolean | false |
getSubRows | (row: TData, index: number) => readonly TData[] | undefined | row => row.subRows when tree is enabled |
expanded | TableExpandedState | - |
columnVisibility | TableColumnVisibilityState | - |
columnToggle | boolean | false |
scrollHeight | string | - |
virtualize | boolean | { estimateSize?: number; overscan?: number } | false |
manualSorting | boolean | false |
manualFiltering | boolean | false |
manualPagination | boolean | false |
getRowId | (row: TData, index: number) => string | row index |
rowClass | (row: TData) => string | undefined | - |
rowStyle | (row: TData) => Record<string, string> | undefined | - |
ui | Partial<Record<TableSlot, string | object>> | - |
Emits
| Event | Payload | Description |
|---|---|---|
update:sorting | TableSortingState | Backs v-model:sorting - fired whenever the sort state changes (see Presort) |
update:rowSelection | TableRowSelectionState | Backs v-model:row-selection - fired whenever the selected rows change |
update:globalFilter | string | Backs v-model:global-filter - fired whenever the global filter value changes |
update:pageIndex | number | Backs v-model:page-index - fired whenever the current page changes (see Pagination) |
update:expanded | TableExpandedState | Backs v-model:expanded - fired whenever row expansion state changes (see Row expansion) |
update:columnVisibility | TableColumnVisibilityState | Backs v-model:column-visibility - fired whenever column visibility changes (see Column visibility toggle) |
rowClick | (row: TData, event: MouseEvent) | Fired when a body row is clicked |
rowContextmenu | (row: TData, event: MouseEvent) | Fired when a body row is right-clicked |
SColumn
| Prop | Type | Default |
|---|---|---|
field | string | - (required) |
header | string | - |
footer | string | - |
sortable | boolean | false |
filterable | boolean | false |
pinned | 'left' | 'right' | - |
SColumnGroup
| Prop | Type | Default |
|---|---|---|
header | string | - |
footer | string | - |
Slots
| Slot | Props | Description |
|---|---|---|
empty | - | Shown when data is empty |
expanded | { row } | Detail content for an expanded row (requires expandable) |
SColumn's own default slot customizes that column's cell content - see
Custom cell content above. SColumn/SColumnGroup
also both take header/footer slots (scoped with { column }) - see
Custom header content above.
Methods
Access these via a template ref on STable.
| Method | Description |
|---|---|
exportCsv(filename?: string) | Downloads all matching rows (across every page) as a CSV file |