Column layout
| Person 1 | Engineering | Senior Software Engineer | Berlin | Germany | $50,000 |
| Person 2 | Design | Product Designer | Lisbon | Portugal | $57,919 |
| Person 3 | Sales | Account Executive | Austin | USA | $65,838 |
| Person 4 | Support | Support Lead | Toronto | Canada | $73,757 |
| Person 5 | Engineering | Senior Software Engineer | Osaka | Japan | $81,676 |
| Person 6 | Design | Product Designer | Berlin | Germany | $89,595 |
| Person 7 | Sales | Account Executive | Lisbon | Portugal | $97,514 |
| Person 8 | Support | Support Lead | Austin | USA | $105,433 |
| Person 9 | Engineering | Senior Software Engineer | Toronto | Canada | $113,352 |
| Person 10 | Design | Product Designer | Osaka | Japan | $121,271 |
<script setup lang="ts">
import { shallowRef } from 'vue'
import { DataTable, useLocalDataSource, useTableState, type ColumnDef } from '@brillliand/vue-table-chad'
import '@brillliand/vue-table-chad/style.css'
type Person = { id: number; name: string; department: string; role: string; city: string; country: string; salary: number }
/*
* Three sizing rules in one column list. A declared `width` is exactly that.
* `city` and `country` declare none and are measured from what they hold — a
* country name is narrower than 160px, a `role` title is not. `role` has
* `flex` instead and takes whatever space the rest of the table leaves over.
*/
const columns: ColumnDef<Person>[] = [
{ id: 'name', header: 'Name', type: 'text', pinned: 'left', width: 160, hideable: false },
{ id: 'department', header: 'Department', type: 'enum', options: ['Engineering', 'Design', 'Sales', 'Support'], width: 140 },
{ id: 'role', header: 'Role', type: 'text', flex: true, minWidth: 120 },
{ id: 'city', header: 'City', type: 'text' },
{ id: 'country', header: 'Country', type: 'text' },
{
id: 'salary',
header: 'Salary',
type: 'number',
align: 'right',
width: 120,
format: (v) => (v == null ? '—' : `$${Number(v).toLocaleString()}`),
},
]
function makePeople(count: number): Person[] {
const roles = ['Senior Software Engineer', 'Product Designer', 'Account Executive', 'Support Lead']
const places = [
['Berlin', 'Germany'],
['Lisbon', 'Portugal'],
['Austin', 'USA'],
['Toronto', 'Canada'],
['Osaka', 'Japan'],
]
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Person ${i + 1}`,
department: ['Engineering', 'Design', 'Sales', 'Support'][i % 4]!,
role: roles[i % 4]!,
city: places[i % places.length]![0]!,
country: places[i % places.length]![1]!,
salary: 50_000 + ((i * 7919) % 90_000),
}))
}
const rows = shallowRef<Person[]>(makePeople(300))
const state = useTableState({ pageSize: 10 })
const source = useLocalDataSource(rows, columns, state.query)
</script>
<template>
<!--
`name` is pinned left and cannot be hidden. Open "Columns ▾" to hide
another, drag a header to reorder it, and drag a column edge to resize —
`storage-key` writes every change to localStorage, so it survives a
reload.
Narrow the window and the flexible `role` column gives up its space first;
widen it and the same column takes it back. Hide enough columns and the
table stops short of the right edge rather than stretching what is left.
-->
<DataTable
:columns="columns"
:source="source"
:state="state"
storage-key="vue-table-chad-docs:column-layout"
/>
</template>Visibility, ordering, resizing and pinning all live in useColumns and are driven from ColumnVisibilityMenu — its panel leads with Show all and Reset layout, above the per-column checkboxes — or programmatically:
const columns = useColumns(defs, { … })
columns.toggleVisibility('email')
columns.moveColumn('salary', 0)
columns.moveColumnTo('salary', 'name', 'after') // what a drop describes
columns.setPinned('name', 'left')
columns.setPinned('name', false) // explicitly unpinned, even if the def says pinned: 'left'
columns.clearPinned('name') // forget the override; the def's pin applies again
columns.setWidth('email', 320)
columns.resetLayout()ColumnDef.pinned is a default, not a lock: setPinned(id, false) records an explicit "unpinned" that outranks it, and clearPinned (or resetLayout) hands control back to the def.
Sticky offsets for pinned columns are recomputed from live widths, so resizing a pinned column shifts the ones pinned after it.
Sizing
Four answers, in this order:
{ id: 'city' } // measured from what it holds
{ id: 'name', width: 200 } // exactly 200px
{ id: 'notes', flex: true } // whatever the other columns leave over
columns.setWidth('city', 300) // a resize outranks all threeA column declaring no width is measured once from the rendered table and clamped into [minWidth ?? 60, maxWidth ?? 160] — so an id column narrows to its digits while a free-text one stops at the cap and truncates. What the clipped text ends with is --vtc-truncation-marker, two dots by default — Firefox is the only engine that implements a custom text-overflow string, so the rest draw the usual … there. The ceiling is useColumns' defaultWidth, which is also the width a column falls back to where nothing can be measured: a server render, a test, the frame before the first layout.
- The measurement reads the first render that has rows, and does not run again on scroll, paging, filtering or sorting — a width that depended on which rows were on screen would change under the reader.
tableRef.remeasureColumns()asks for a new answer after swapping the dataset for one whose cells are a different size. - Measured widths are not persisted and are not what
resetWidthclears. Resetting a resized column lands back on its measured width;storageFieldsstill governs the widths a user dragged. minWidthandmaxWidthare the knobs.maxWidth: 400lets one column run wider than the rest;minWidth: 120keeps a short column from collapsing to its header.
Without a flex column the table is exactly as wide as its columns and the space to the right stays empty; with several they share it equally. flex is ignored on a pinned column and warns, because a sticky offset is the sum of the widths before it and a column with no width of its own cannot be summed. Resizing a flex column fixes it at a number until resetWidth hands it back to the leftover.
.vt-table[data-fill] is the styling hook for the fill case, and ColumnDef.resizable: false opts a column out of being dragged at all.
Remembering the layout
One prop persists the layout — visibility, order, widths and pins — to localStorage and restores it on the next visit:
<DataTable :columns="columns" :source="source" storage-key="employees:layout" />
<!-- let widths follow the viewport instead of the user -->
<DataTable
:columns="columns"
:source="source"
storage-key="employees:layout"
:storage-fields="['hidden', 'order', 'pinned']"
/>Same thing from the composable, where a bare string is shorthand for { key }:
const columns = useColumns(defs, { storage: 'employees:layout' })
const columns = useColumns(defs, {
storage: { key: 'employees:layout', fields: ['hidden', 'order'], storage: sessionStorage },
})
columns.clearStored() // forget the saved entry, keep the live layout- All four parts of the layout are saved by default. Narrow it with
fieldswhen something is per-screen rather than per-user — widths are the usual candidate. - A saved layout wins over
initialLayout, field by field —initialLayoutremains the first-visit default for anything not saved. - Both are read once at setup, so changing
storage-keyon a mounted table does nothing;:keythe table if you need to switch saved views. - Corrupt, foreign or partially-malformed JSON is discarded rather than thrown; unavailable storage (SSR, private mode, quota) degrades to an in-memory layout.
- Ids that no longer exist in
columnsare kept in the saved entry, sinceuseColumnsignores unresolvable ids anyway — a column that comes back later keeps its place.
The pieces are exported for hand-rolled cases (a "saved views" dropdown, syncing to a server): readColumnLayout, writeColumnLayout, clearColumnLayout, sanitizeColumnLayout.
Drag to reorder
Header cells are drag sources out of the box. useColumnDnd owns the interaction; TableRoot wires it into the context, TableHeaderCell reports hits from its own box, and ColumnDragGhost renders the label that follows the pointer.
<DataTable :columns="columns" :source="source" @update:column-order="save" />
<DataTable :columns="columns" :source="source" :reorderable="false" /> <!-- off -->Per column: { id: 'actions', reorderable: false }.
- A press only becomes a drag after 4px, so clicking a header still sorts it — and the
clickthat follows a real drag is swallowed, so a drop never sorts. - Dropping on a pinned column adopts that column's pin side; otherwise the reorder would be invisible, since pinned columns are hoisted to the edges regardless of order.
- Hidden columns keep their place: a drop is stored as "before/after this column", not as an index into the visible list.
- Keyboard equivalent:
Alt+←/→on a focused header.Esccancels a drag in flight. @update:column-orderfires for every order change, dragged or not — persist it and feed it back throughinitialLayout.order.
Styling hooks: [data-reorderable], [data-dragging] and [data-drop='before'|'after'] on the <th>, plus .vt-drag-ghost.
Live: the Column layout tab of pnpm demo (#columns). Back to the docs index.