Skip to content

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
Page 1 of 2
vue
<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

vue-html
<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:

vue-html
<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 Johnson29New YorkEngineering
Bob Smith34AustinSales
Cara Lee41SeattleEngineering
vue-html
<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:

vue-html
<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:

vue-html
<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 JohnsonAdmin
Bob SmithMember
vue-html
<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
1001Alice Johnson$120.00
1002Bob Smith$45.50
vue-html
<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
vue-html
<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
vue-html
<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
#1001Alice Johnson42
#1002Bob Smith128
#1003Cara Lee76
vue-html
<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
vue-html
<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 1EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 2EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 3EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 4EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 5EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 6EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 7EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
Employee 8EngineeringSoftware Engineer[email protected]+1 555 0100San Francisco2023-01-15
vue-html
<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
vue-html
<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:

vue-html
<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.

vue-html
<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]
vue-html
<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
vue-html
<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:

vue-html
<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:

vue
<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:

Theme source "table" not found

Props

STable

PropTypeDefault
dataTData[]-
columnsTableColumnDef<TData>[]-
selectablebooleanfalse
selectOnRowClickbooleanfalse
rowHoverbooleanfalse
pageSizenumber- (no pagination until set)
loadingbooleanfalse
sortingSortingState-
defaultSortingSortingState-
rowSelectionTableRowSelectionState-
globalFilterstring-
pageIndexnumber-
size'sm' | 'md' | 'lg''md'
gridlinesbooleanfalse
stripedbooleanfalse
expandablebooleanfalse
treebooleanfalse
getSubRows(row: TData, index: number) => readonly TData[] | undefinedrow => row.subRows when tree is enabled
expandedTableExpandedState-
columnVisibilityTableColumnVisibilityState-
columnTogglebooleanfalse
scrollHeightstring-
virtualizeboolean | { estimateSize?: number; overscan?: number }false
manualSortingbooleanfalse
manualFilteringbooleanfalse
manualPaginationbooleanfalse
getRowId(row: TData, index: number) => stringrow index
rowClass(row: TData) => string | undefined-
rowStyle(row: TData) => Record<string, string> | undefined-
uiPartial<Record<TableSlot, string | object>>-

Emits

EventPayloadDescription
update:sortingTableSortingStateBacks v-model:sorting - fired whenever the sort state changes (see Presort)
update:rowSelectionTableRowSelectionStateBacks v-model:row-selection - fired whenever the selected rows change
update:globalFilterstringBacks v-model:global-filter - fired whenever the global filter value changes
update:pageIndexnumberBacks v-model:page-index - fired whenever the current page changes (see Pagination)
update:expandedTableExpandedStateBacks v-model:expanded - fired whenever row expansion state changes (see Row expansion)
update:columnVisibilityTableColumnVisibilityStateBacks 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

PropTypeDefault
fieldstring- (required)
headerstring-
footerstring-
sortablebooleanfalse
filterablebooleanfalse
pinned'left' | 'right'-

SColumnGroup

PropTypeDefault
headerstring-
footerstring-

Slots

SlotPropsDescription
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.

MethodDescription
exportCsv(filename?: string)Downloads all matching rows (across every page) as a CSV file
Edit this page