Table
A sortable data table with a card-styled header, per-column custom cell/header slots and optional drag-to-resize columns.
<UiTable :data="..." :columns="..." />Options
One block per prop — what it does, and what it looks like set.
Data
requireddata 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.
Columns
requiredcolumns 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.
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
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.
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.
Sticky
sticky pins the header to the top of its scroll container, so column names stay readable through a long list.
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.
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.
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.
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.
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.
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.
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.
API Reference
Generated from the component's source — props, slots and emits as the component actually declares them.
Props
datarequiredRecord<string, unknown>[](row: Record<string, unknown>) => voidTableSort | nullbooleanfalseRecord<string, number>"subtle" | "soft"'subtle'Slots
${column.accessorKey}-headerloading${column.accessorKey}-cellemptyEmits
update:sort[value: TableSort | null]update:columnWidths[value: Record<string, number>]Types
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;
}interface TableSort {
column: string;
direction: "asc" | "desc";
}