Table

A sortable data table with a card-styled header, per-column custom cell/header slots and optional drag-to-resize columns.

vue
<UiTable :data="..." :columns="..." />

Options

One block per prop — what it does, and what it looks like set.

Data

required

data is the rows, one plain object each. The table reads values off them through each column's accessorKey and otherwise leaves them alone, so extra keys a column doesn't name are carried along untouched — handy, since the whole row object is what onSelect and the cell slots hand back.

Status
Jane Doe
jane@bundy.dev
Active
$1,204
John Smith
john@bundy.dev
Invited
$820
Ava Chen
ava@bundy.dev
Active
$3,410
Liam Brown
liam@bundy.dev
Suspended
$0

Columns

required

columns defines what's shown and in what order. Each entry needs an accessorKey naming the key to read, plus an optional header (falling back to the key itself), a fixed width such as '120px', an align of start/center/end, and sortable to make its header clickable. Columns without a width share whatever space is left equally.

name
email
status
amount
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0
Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

Column cells default to rendering the raw value as text; override per column with a #<accessorKey>-cell slot for anything richer (badges, formatted numbers, etc.).

Caption

caption is a line of text above the table, naming what the rows are. It's ordinary prose rather than a heading, so it's the place to say what a reader needs before the first row makes sense.

Team members and their account status

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

Empty

empty is the text shown in place of rows when data is empty. Say what's missing and, where you can, what would fill it — the empty slot takes over entirely if you need a button rather than a sentence.

Status
No members yet

Loading

loading swaps the rows for the loading slot, which is a set of skeleton rows unless you replace it. Prefer it to hiding the table behind a v-if — the header and the column widths stay put, so the page doesn't jump when the data lands.

Status

Sticky

sticky pins the header to the top of its scroll container, so column names stay readable through a long list.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

The header renders as its own Card, visually separate from the body rows — that's deliberate, not a layout bug.

On select

onSelect makes rows clickable — passing it is what adds the hover highlight and the pointer cursor, so there's no separate flag to turn that on. It's handed the whole row object, not an index, which is why a row can carry an id the columns never render.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0
Selected: no row yet

Sort

sort takes the active sort out of the table's hands: pass a { column, direction } object — or null for none — and clicking a sortable header only emits update:sort rather than reordering anything. That's the hook for server-side sorting, where the new order arrives as fresh data.

Ava Chen
ava@bundy.dev
Active
3410
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Liam Brown
liam@bundy.dev
Suspended
0
Sorted by name asc — the reordering below is done outside the table

Sorting is fully client-side and always runs against data — if you're doing server-side sorting, pass sort/@update:sort and just don't rely on the local re-sort changing anything (sorting already-sorted data by the same key is a no-op).

Default sort

defaultSort is the sort applied on mount while the table still manages its own order. Use it to open on the column that makes the list readable — newest first, largest first — instead of whatever order the array happened to arrive in.

Status
Ava Chen
ava@bundy.dev
Active
3410
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Liam Brown
liam@bundy.dev
Suspended
0

Resizable

resizable puts a drag handle on the right edge of every header cell, shown when the pointer is over it. Dragging it fixes that column at a pixel width, and the columns without a width give up the room — never below their own minWidth, so a column can't be widened out of the card. The handles take arrow keys when focused, and double-clicking one puts the column back where it started. A column's own resizable overrides the table's, either way.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

A resized column becomes a fixed pixel track, and only columns *without* a width give up room for it. If every column has a width, there's nothing to take from, so a column can only grow into space the card already has spare — give at least one column no width if the others should stay widenable.

Column widths

columnWidths holds the resized widths in pixels, keyed by accessorKey. Leave it off and the table keeps them itself; bind it with v-model:column-widths to read them back — to persist a user's layout, say — or to set a starting width without touching columns.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0
Widths: {"name":180}

Variant

variant sets the header row's surface. subtle, the default, is a raised panel with a faint ring; soft is a flat tint with no ring, matching soft cards and buttons, and has its own section below.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

Examples

The component in real use: compositions that answer "how do I build X with this", rather than one prop at a time.

Soft header

variant='soft' swaps the raised header for a flat tint with no ring. A sticky soft header is drawn opaque, so rows don't show through it as they scroll.

Status
Jane Doe
jane@bundy.dev
Active
1204
John Smith
john@bundy.dev
Invited
820
Ava Chen
ava@bundy.dev
Active
3410
Liam Brown
liam@bundy.dev
Suspended
0

API Reference

Generated from the component's source — props, slots and emits as the component actually declares them.

Props

Prop
Type
Default
Description
datarequired
Record<string, unknown>[]
—
The rows to render, one object per row, read via each column's `accessorKey`.
columnsrequired
TableColumn[]
—
The columns to render, in order.
string
—
Text displayed above the table.
string
'No data'
Text displayed when `data` is empty. Ignored when the `empty` slot is used.
boolean
false
Shows the loading slot instead of rows.
boolean
false
Keeps the header pinned to the top of its scroll container.
(row: Record<string, unknown>) => void
—
Called when a row is clicked. Rows become clickable (hover highlight, pointer cursor) when set.
TableSort | null
—
Controls the active sort. Omit this to let the table manage its own sort state internally (starting from `defaultSort`); pass it to fully control it yourself (e.g. for server-side sorting).
TableSort | null
—
The sort applied when uncontrolled.
boolean
false
Shows a drag handle on every column's header edge to resize it. A column's own `resizable` wins over this. Double-clicking a handle puts the column back to its `width`.
Record<string, number>
—
The resized column widths, in pixels, keyed by `accessorKey` — a column missing from it keeps its `width`. Omit this to let the table keep them itself; bind it with `v-model:column-widths` to read or persist them.
"subtle" | "soft"
'subtle'
The header row's surface, named after `Card`'s variants. `subtle` is a raised panel with a faint ring and `muted` column names; `soft` a flat tint with no ring, matching soft cards and buttons, with lighter `toned` column names.

Slots

Slot
${column.accessorKey}-header
Dynamic — one slot per item, named from the expression shown. Receives `{ column }`.
loading
${column.accessorKey}-cell
Dynamic — one slot per item, named from the expression shown. Receives `{ row, value }`.
empty

Emits

Event
Payload
Description
update:sort
[value: TableSort | null]
—
update:columnWidths
[value: Record<string, number>]
—

Types

ts
interface TableColumn {
    /**
     * The key used to read this column's value off each row in `data`.
     */
    accessorKey: string;

    /**
     * Header text for this column. Falls back to `accessorKey`. Ignored when the `#<accessorKey>-header` slot is used.
     */
    header?: string;

    /**
     * A fixed track size (e.g. `'160px'`) for this column. Defaults to sharing the remaining space equally with other unset columns.
     */
    width?: string;

    /**
     * Text alignment for this column's header and cells.
     * @defaultValue 'start'
     */
    align?: "start" | "center" | "end";

    /**
     * Makes the header clickable to sort by this column.
     * @defaultValue false
     */
    sortable?: boolean;

    /**
     * Shows a drag handle on this column's header edge to resize it. Overrides the table's `resizable`.
     */
    resizable?: boolean;

    /**
     * The narrowest, in pixels, resizing can make this column.
     * @defaultValue 64
     */
    minWidth?: number;

    /**
     * The widest, in pixels, resizing can make this column. Resizing never grows a column past the
     * room the other columns can give up, whatever this is.
     */
    maxWidth?: number;
}
ts
interface TableSort {
    column: string;
    direction: "asc" | "desc";
}