Table Layout #
Data table with resizable columns, sortable headers, and cell-based rendering — powered by vlist's core virtualization.
Overview #
The withTable feature transforms a virtual list into a full data table. Instead of building a table from scratch, it leverages all of vlist's core functionalities — virtualization, element pooling, size caching, scroll compression, and the builder composition model — and layers column-aware rendering on top.
Rows are the unit of virtualization, exactly like a plain list. The table feature adds:
- A sticky header row with column labels and resize handles
- Cell-based rendering — each row contains N absolutely positioned cells
- Column layout — widths, offsets, min/max constraints, flex distribution
- Resize interaction — drag column borders to resize
- Sort events — click sortable headers, consumer re-orders data
Because the table is built on vlist's core, you get everything else for free: withSelection for row selection, withScrollbar for custom scrollbars, withAsync for lazy data loading, withScale for million-row datasets, and withSnapshots for scroll position save/restore.
Architecture #
┌─────────────────────────────────────────────┐
│ vlist core │
│ ┌──────────────────────────────────────┐ │
│ │ Virtualization engine │ │
│ │ (size cache, range calc, overscan) │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ Element pooling & change tracking │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ Scroll controller & compression │ │
│ └──────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ withTable (this feature) │
│ ┌──────────────┐ ┌──────────────────────┐ │
│ │ TableLayout │ │ TableHeader │ │
│ │ (col widths, │ │ (sticky, resize, │ │
│ │ offsets, │ │ sort indicators, │ │
│ │ resize) │ │ scroll sync) │ │
│ └──────────────┘ └──────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ TableRenderer │ │
│ │ (cell-based rows, column positioning,│ │
│ │ pooling, change tracking) │ │
│ └──────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Composable features (all work unchanged) │
│ withSelection · withScrollbar · withAsync │
│ withScale · withSnapshots │
└─────────────────────────────────────────────┘
What vlist Core Provides (used by withTable) #
| Core capability | How withTable uses it |
|---|---|
| Row virtualization | Only visible rows exist in the DOM — table rows are items |
| Size cache | Fixed or variable row heights, prefix-sum offset lookups |
| Element pooling | Row elements are recycled, not created/destroyed on scroll |
| Change tracking | Cells are only re-rendered when item data or state changes |
| Scroll controller | Smooth scrolling, velocity tracking, idle detection |
| Scroll compression | Million-row tables via withScale — no changes needed |
| Resize observer | Column widths for flex columns recalculate on container resize |
| Event emitter | Table adds column:resize, column:sort, column:click events |
| Builder composition | withSelection, withScrollbar, etc. compose unchanged |
Key Features #
- ✅ Column Definitions — Declarative column config with key, label, width, min/max, alignment
- ✅ Cell Templates — Per-column render functions, or automatic
item[key]text - ✅ Resizable Columns — Drag header borders with min/max constraints
- ✅ Sortable Headers — Click to cycle asc → desc → none (consumer sorts data)
- ✅ Sticky Header — Fixed above viewport, horizontally synced with body scroll
- ✅ Flex Columns — Columns without explicit width share remaining space equally
- ✅ Horizontal Scroll — When total column width exceeds container
- ✅ Variable Row Heights — Fixed number, function per index, or estimated (auto-measure)
- ✅ Full ARIA —
role="grid",role="row",role="gridcell",aria-colindex,aria-sort - ✅ Composable — Works with selection, scrollbar, async, scale, snapshots
Quick Start #
import { vlist, withTable, withSelection } from 'vlist'
const table = vlist({
container: '#my-table',
item: { height: 40, template: () => '' },
items: users,
})
.use(withTable({
columns: [
{ key: 'name', label: 'Name', width: 220, sortable: true },
{ key: 'email', label: 'Email', width: 280, sortable: true },
{ key: 'department', label: 'Department', width: 140, sortable: true },
{ key: 'role', label: 'Role', width: 180 },
],
rowHeight: 40,
headerHeight: 44,
}))
.use(withSelection({ mode: 'single' }))
.build()
HTML Structure #
<div id="my-table" style="height: 600px;"></div>
Result #
The table feature will:
- Create a sticky header row with column labels
- Render resize handles at column borders
- Replace the list renderer with a cell-based table renderer
- Position cells absolutely using column offsets and widths
- Sync header scroll with viewport scroll (horizontal)
- Add
.vlist--tableclass to the root androle="grid"for accessibility
DOM Structure #
.vlist.vlist--table [role="grid"]
├── .vlist-table-header [role="row"] ← sticky, above viewport
│ ├── .vlist-table-header-scroll ← scrolls in sync with body
│ │ ├── .vlist-table-header-cell [role="columnheader"]
│ │ │ ├── .vlist-table-header-content ← label text
│ │ │ ├── .vlist-table-header-sort ← ▲/▼ indicator
│ │ │ └── .vlist-table-header-resize ← drag handle
│ │ ├── .vlist-table-header-cell
│ │ └── ...
├── .vlist-viewport ← scrollable area
│ └── .vlist-content ← total height spacer
│ └── .vlist-items ← positioned rows
│ ├── .vlist-item.vlist-table-row [role="row"]
│ │ ├── .vlist-table-cell [role="gridcell"]
│ │ ├── .vlist-table-cell
│ │ └── ...
│ ├── .vlist-item.vlist-table-row
│ └── ...
Configuration #
TableConfig #
interface TableConfig<T extends VListItem = VListItem> {
/** Column definitions (required, at least one) */
columns: TableColumn<T>[]
/** Row height — fixed number or function per index. Omit when using estimatedRowHeight. */
rowHeight?: number | ((index: number) => number)
/** Estimated row height for auto-measurement (Mode B). Requires withAutoSize(). */
estimatedRowHeight?: number
/** Header height in pixels (default: rowHeight or 40) */
headerHeight?: number
/** Enable column resizing globally (default: true) */
resizable?: boolean
/** Default minimum column width (default: 50) */
minColumnWidth?: number
/** Default maximum column width (default: Infinity) */
maxColumnWidth?: number
/** Show vertical borders between columns (default: false) */
columnBorders?: boolean
/** Show horizontal borders between rows (default: true) */
rowBorders?: boolean
/** Initial sort state (visual indicator only) */
sort?: { key: string; direction: 'asc' | 'desc' }
}
TableColumn #
interface TableColumn<T extends VListItem = VListItem> {
/** Unique column key (maps to item property by default) */
key: string
/** Header label — string or DOM element */
label: string | HTMLElement
/** Initial width in pixels (omit for flex distribution) */
width?: number
/** Minimum width in pixels (default: 50) */
minWidth?: number
/** Maximum width in pixels (default: Infinity) */
maxWidth?: number
/** Allow resizing (default: inherits from config.resizable) */
resizable?: boolean
/** Cell template — renders cell content */
cell?: (item: T, column: TableColumn<T>, rowIndex: number) => string | HTMLElement
/** Header template — custom header cell render */
header?: (column: TableColumn<T>) => string | HTMLElement
/** Text alignment (default: 'left') */
align?: 'left' | 'center' | 'right'
/** Enable sort indicator on click (default: false) */
sortable?: boolean
}
Column Width Resolution #
Columns are resolved in this order:
- Columns with explicit
widthget their requested size (clamped to min/max) - Remaining container width is distributed equally among columns without
width - If all columns have explicit widths and total < container, no stretching occurs
- If total column width > container, horizontal scrolling is enabled
// Fixed widths — total 600px, scrolls if container < 600px
columns: [
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email', width: 300 },
{ key: 'role', label: 'Role', width: 100 },
]
// Mix of fixed and flex — email takes remaining space
columns: [
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email' }, // flex
{ key: 'role', label: 'Role', width: 100 },
]
// All flex — each gets containerWidth / 3
columns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' },
]
Row Height Options #
Like all vlist features, the table supports three height modes:
Mode A — Fixed Height #
withTable({
columns: [...],
rowHeight: 40,
})
Mode A — Variable Height (Function) #
withTable({
columns: [...],
rowHeight: (index) => items[index].isExpanded ? 80 : 40,
})
Mode B — Estimated Height (Auto-Measure) #
withTable({
columns: [...],
estimatedRowHeight: 48, // measure actual height after render
})
Cell Templates #
Default (Auto) #
When no cell function is provided, the table renders String(item[column.key]):
columns: [
{ key: 'name', label: 'Name' }, // → item.name as text
{ key: 'email', label: 'Email' }, // → item.email as text
]
Custom Cell Templates #
Use the cell function for rich content:
columns: [
{
key: 'name',
label: 'Name',
width: 220,
cell: (item) => `
<div class="name-cell">
<div class="avatar" style="background:${item.color}">${item.initials}</div>
<span>${item.firstName} ${item.lastName}</span>
</div>
`,
},
{
key: 'status',
label: 'Status',
width: 100,
align: 'center',
cell: (item) => {
const active = item.active
const cls = active ? 'badge--active' : 'badge--inactive'
return `<span class="badge ${cls}">${active ? 'Active' : 'Inactive'}</span>`
},
},
{
key: 'actions',
label: '',
width: 60,
resizable: false,
cell: (item) => `<button onclick="edit(${item.id})">✏️</button>`,
},
]
Custom Header Templates #
columns: [
{
key: 'name',
label: 'Name',
header: (col) => `
<div class="custom-header">
<span class="icon">👤</span>
<span>${col.label}</span>
</div>
`,
},
]
Sorting #
The table feature does not sort data. It provides visual indicators and emits events — the consumer is responsible for re-ordering items.
Setup #
Mark columns as sortable:
columns: [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role' }, // not sortable
]
Handling Sort Events #
const table = vlist({ ... })
.use(withTable({ columns, rowHeight: 40 }))
.build()
let sortKey = null
let sortDir = 'asc'
table.on('column:sort', ({ key, direction }) => {
if (direction === null) {
// Sort cleared — restore original order
table.setItems(originalItems)
table.setSort(null)
} else {
sortKey = key
sortDir = direction
const sorted = [...originalItems].sort((a, b) => {
const cmp = String(a[key]).localeCompare(String(b[key]))
return direction === 'desc' ? -cmp : cmp
})
table.setItems(sorted)
table.setSort(key, direction)
}
})
Sort Cycle #
Clicking a sortable header cycles through:
(none) → ascending (▲) → descending (▼) → (none) → ...
Programmatic Sort #
// Set sort indicator (visual only — does NOT reorder data)
table.setSort('name', 'asc')
// Clear sort indicator
table.setSort(null)
// Read current sort state
const { key, direction } = table.getSort()
Column Resizing #
Drag to Resize #
Hover over a column border in the header to reveal the resize handle. Click and drag to resize. The handle enforces minWidth and maxWidth constraints.
During drag:
- The root element gets
.vlist--col-resizing(setscursor: col-resizeglobally) - The active handle gets a colored border
- All rendered rows update their cell widths in real time
Resize Events #
table.on('column:resize', ({ key, index, previousWidth, width }) => {
console.log(`Column "${key}" resized: ${previousWidth}px → ${width}px`)
})
Programmatic Resize #
// Resize by column key
table.resizeColumn('email', 350)
// Resize by column index
table.resizeColumn(1, 350)
// Get current widths
const widths = table.getColumnWidths()
// → { name: 220, email: 350, role: 140 }
Non-Resizable Columns #
columns: [
{ key: 'id', label: '#', width: 60, resizable: false }, // locked
{ key: 'name', label: 'Name', width: 200 }, // resizable
]
Runtime Column Updates #
Update column definitions without destroying the table:
// Switch to a compact column set
table.updateColumns([
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email' },
])
// Switch to a full column set
table.updateColumns([
{ key: 'id', label: '#', width: 60, resizable: false },
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email', width: 240 },
{ key: 'company', label: 'Company', width: 160 },
{ key: 'department', label: 'Department', width: 130 },
{ key: 'role', label: 'Role', width: 170 },
{ key: 'city', label: 'City', width: 120 },
{ key: 'phone', label: 'Phone', width: 140 },
])
API Reference #
withTable(config) #
Feature factory. Returns a VListFeature to pass to .use().
import { vlist, withTable } from 'vlist'
const table = vlist({
container: '#table',
item: { height: 40, template: () => '' },
items: data,
})
.use(withTable({
columns: [...],
rowHeight: 40,
headerHeight: 44,
resizable: true,
columnBorders: false,
rowBorders: true,
}))
.build()
Priority: 10 (runs before other features — replaces the renderer)
Conflicts with: withGrid, withMasonry (only one layout mode at a time)
Cannot combine with: orientation: 'horizontal', reverse: true
Instance Methods #
When withTable is active, the list instance exposes:
| Method | Signature | Description |
|---|---|---|
updateColumns |
(columns: TableColumn[]) => void |
Replace column definitions at runtime |
resizeColumn |
(keyOrIndex: string | number, width: number) => void |
Resize a column programmatically |
getColumnWidths |
() => Record<string, number> |
Get current column widths as { key: px } |
setSort |
(key: string | null, direction?: 'asc' | 'desc') => void |
Set sort indicator (visual only) |
getSort |
() => { key: string | null, direction: 'asc' | 'desc' } |
Get current sort state |
Events #
| Event | Payload | Description |
|---|---|---|
column:resize |
{ key, index, previousWidth, width } |
A column was resized |
column:sort |
{ key, index, direction } |
A sortable header was clicked |
column:click |
{ key, index, event } |
Any header cell was clicked |
Plus all standard vlist events (scroll, range:change, item:click, selection:change, etc.).
Combining with Other Features #
Table + Selection #
const table = vlist({ ... })
.use(withTable({ columns, rowHeight: 40 }))
.use(withSelection({ mode: 'multiple' }))
.build()
table.on('selection:change', ({ selected, items }) => {
console.log(`${selected.length} rows selected`)
})
Table + Scrollbar #
const table = vlist({ ... })
.use(withTable({ columns, rowHeight: 40 }))
.use(withScrollbar({ autoHide: true }))
.build()
Table + Scale (Large Datasets) #
// 1,000,000 rows — scroll compression handles the DOM size limit
const table = vlist({
container: '#table',
item: { height: 36, template: () => '' },
items: millionRows,
})
.use(withTable({ columns, rowHeight: 36 }))
.use(withScale())
.use(withScrollbar())
.build()
Table + Async (Lazy Loading) #
const table = vlist({
container: '#table',
item: { height: 40, template: () => '' },
// no items — loaded via adapter
})
.use(withTable({ columns, rowHeight: 40 }))
.use(withAsync({
adapter: {
read: async ({ offset, limit }) => {
const res = await fetch(`/api/users?offset=${offset}&limit=${limit}`)
const data = await res.json()
return { items: data.users, total: data.total, hasMore: data.hasMore }
},
},
}))
.use(withSelection({ mode: 'single' }))
.build()
Table + Snapshots #
const table = vlist({ ... })
.use(withTable({ columns, rowHeight: 40 }))
.use(withSnapshots())
.build()
// Save scroll position
const snapshot = table.getScrollSnapshot()
localStorage.setItem('table-scroll', JSON.stringify(snapshot))
// Restore later
const saved = JSON.parse(localStorage.getItem('table-scroll'))
table.restoreScroll(saved)
Table + Groups (Sectioned Data Tables) #
Groups work seamlessly with the table feature. Group headers render as full-width rows without cells, and sticky group headers sit below the table's column header row:
const table = vlist({
container: '#employees',
items: sortedEmployees, // must be pre-sorted by group
item: { height: 40, template: () => '' },
})
.use(withTable({
columns: [
{ key: 'name', label: 'Name', width: 220 },
{ key: 'email', label: 'Email', width: 280 },
{ key: 'department', label: 'Department', width: 160 },
],
rowHeight: 40,
headerHeight: 44,
}))
.use(withGroups({
getGroupForIndex: (i) => sortedEmployees[i].department,
header: {
height: 32,
template: (dept) => `<div class="group-label">${dept}</div>`,
},
sticky: true,
}))
.build()
How it works:
- Group headers are full-width rows with
role="presentation"— no cells, not selectable - Sticky group headers are offset by the table column header height (positioned below it)
- Column resize updates group header width alongside data rows
- The table renderer is configured in-place (not replaced) — safer than the grid path because the table renderer manages cells, alignment, and resize handles
Z-index layering:
| Layer | Element | z-index |
|---|---|---|
| Column header | .vlist-table-header |
5 |
| Sticky group header | .vlist-sticky-header |
4 |
| Data rows | .vlist-table-row |
— |
| Group header rows | .vlist-table-group-header |
— |
Performance #
Virtualization #
The table inherits vlist's core virtualization — only visible rows exist in the DOM. For a table with 100,000 rows and 10 columns:
- Without virtualization: 1,000,000 cell elements
- With vlist table: ~150 cell elements (15 visible rows × 10 columns)
Change Tracking #
The table renderer tracks each row's state:
- Item ID unchanged — skip template re-evaluation entirely
- Selection/focus changed — update CSS classes only (no DOM rebuild)
- Position changed — update
transform: translateY()only - Height changed — update
style.heightonly - Nothing changed — zero DOM operations
When groups are active, group header rows have their own fast path: only the group key (item ID) is tracked — no selection or focus state to compare.
Element Pooling #
Row elements are pooled and reused. When a row scrolls out of view, its DOM element is released to the pool (after a grace period to prevent boundary thrashing). When a new row enters the viewport, it reuses a pooled element instead of createElement.
Cell Width Updates #
When columns are resized, only the left and width CSS properties on existing cells are updated — no re-rendering of cell content.
Styling #
CSS Classes #
| Class | Element | Description |
|---|---|---|
.vlist--table |
Root | Added when table feature is active |
.vlist--col-resizing |
Root | Added during column drag resize |
.vlist-table-header |
Header row | Sticky header container |
.vlist-table-header-cell |
Header cell | Individual column header |
.vlist-table-header-cell--sortable |
Header cell | Sortable columns get pointer cursor |
.vlist-table-header-content |
Header label | Text content wrapper |
.vlist-table-header-sort |
Sort indicator | ▲/▼ character |
.vlist-table-header-resize |
Resize handle | Drag handle at column border |
.vlist-table-row |
Data row | Row element (also has .vlist-item) |
.vlist-table-cell |
Data cell | Individual cell |
.vlist-item--selected |
Data row | Selected row |
.vlist-item--focused |
Data row | Keyboard-focused row |
.vlist-table-group-header |
Group header row | Full-width row, no cells (with withGroups) |
.vlist-table-group-header-content |
Group header content | Single content container inside group header |
CSS Custom Properties #
The table respects all standard vlist custom properties:
[data-theme-mode="light"] {
--vlist-bg: #ffffff;
--vlist-bg-hover: #f9fafb;
--vlist-bg-selected: #eff6ff;
--vlist-border: #e5e7eb;
--vlist-text: #111827;
--vlist-text-muted: #6b7280;
--vlist-focus-ring: #3b82f6;
--vlist-item-padding-x: 0.75rem;
}
Example Styles #
/* Header — uppercase labels */
.vlist-table-header {
text-transform: uppercase;
font-size: 0.6875rem;
letter-spacing: 0.04em;
}
/* Cells — smaller font */
.vlist-table-cell {
font-size: 0.875rem;
}
/* Zebra striping */
.vlist-table-row:nth-child(even) {
background: var(--vlist-bg-hover);
}
/* Column border variant */
.vlist--table-col-borders .vlist-table-header-cell {
border-right-color: var(--vlist-border);
}
Custom Class Prefix #
const table = vlist({
container: '#table',
classPrefix: 'dt',
item: { height: 40, template: () => '' },
items: data,
})
.use(withTable({ columns, rowHeight: 40 }))
.build()
// Classes become: .dt--table, .dt-table-header, .dt-table-cell, etc.
Best Practices #
1. Keep Cell Templates Simple #
Cell templates run for every visible row on every scroll frame that introduces new items. Keep them lightweight:
// ✅ Good — minimal DOM
cell: (item) => `<span>${item.name}</span>`
// ❌ Avoid — complex nested DOM
cell: (item) => `
<div class="wrapper">
<div class="inner">
<div class="icon-container">
<svg>...</svg>
</div>
<div class="text-container">
<span class="primary">${item.name}</span>
<span class="secondary">${item.subtitle}</span>
</div>
</div>
</div>
`
2. Let the Consumer Handle Sorting #
The table intentionally does not sort data. This keeps the feature focused and gives you full control:
// ✅ Consumer sorts — full control over sort algorithm, locale, special keys
table.on('column:sort', ({ key, direction }) => {
const sorted = myCustomSort(data, key, direction)
table.setItems(sorted)
table.setSort(key, direction)
})
3. Use Min/Max Width Constraints #
Prevent columns from becoming unusably small or wasting space:
columns: [
{ key: 'name', label: 'Name', width: 200, minWidth: 120, maxWidth: 400 },
{ key: 'id', label: '#', width: 60, minWidth: 50, maxWidth: 80, resizable: false },
]
4. Use Flex Columns for Responsive Tables #
Omit width on one or more columns to let them fill remaining space:
columns: [
{ key: 'name', label: 'Name', width: 200 }, // fixed
{ key: 'email', label: 'Email' }, // flex — takes remaining space
{ key: 'role', label: 'Role', width: 120 }, // fixed
]
5. Persist Column Widths #
Save user's resize preferences:
table.on('column:resize', () => {
const widths = table.getColumnWidths()
localStorage.setItem('table-widths', JSON.stringify(widths))
})
// Restore on next load
const saved = JSON.parse(localStorage.getItem('table-widths') || '{}')
for (const [key, width] of Object.entries(saved)) {
table.resizeColumn(key, width)
}
6. Use Column Presets for Different Views #
const PRESETS = {
compact: [
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email' },
],
full: [
{ key: 'id', label: '#', width: 60 },
{ key: 'name', label: 'Name', width: 200 },
{ key: 'email', label: 'Email', width: 240 },
{ key: 'company', label: 'Company', width: 160 },
{ key: 'department', label: 'Department', width: 130 },
{ key: 'role', label: 'Role', width: 170 },
{ key: 'phone', label: 'Phone', width: 140 },
],
}
// Switch presets at runtime
table.updateColumns(PRESETS.full)
Troubleshooting #
Table not rendering #
- Ensure you have at least one column in the
columnsarray - Ensure
rowHeightorestimatedRowHeightis provided - The container must have a height (
height: 600pxor flex layout)
Columns too narrow or too wide #
- Check
minWidth/maxWidthconstraints - If all columns have explicit widths and total < container, no stretching occurs — add a flex column
- If total > container, horizontal scrolling is expected
Horizontal scroll not working #
- The table enables horizontal scroll automatically when total column width > container
- Ensure the container doesn't have
overflow: hiddenset externally
Header not syncing with body scroll #
- The header syncs via
translateXon the scroll container - If you override
.vlist-table-headerstyles, ensureoverflow: hiddenis preserved
Sort indicator not showing #
- Ensure the column has
sortable: true - After handling the
column:sortevent, calltable.setSort(key, direction)to update the visual - The table does NOT sort data — you must call
table.setItems(sorted)yourself
Resize handle not visible #
- Handles appear on hover — they are transparent by default
- Ensure your CSS doesn't set
pointer-events: noneon header cells - Non-resizable columns (
resizable: false) don't show handles
Performance with many columns #
- Keep cell templates minimal
- For 20+ columns, ensure horizontal scrolling is working (not all columns squeezed)
- Consider column presets to let users choose which columns to show
Module Structure #
src/features/table/
├── index.ts # Public exports
├── feature.ts # withTable() feature factory
├── types.ts # TableConfig, TableColumn, interfaces
├── layout.ts # Column width resolution, resize, offsets
├── header.ts # Sticky header row, resize handles, sort indicators
└── renderer.ts # Cell-based row rendering with pooling
See Also #
- Events — `column:resize`, `column:sort`, `column:click` — Table-specific events for resize, sort, and header clicks
- Types — `GridSizeContext` — Size context also used by table row height functions
- Groups — Full-width group headers in data tables, sticky headers sit below column header
- Selection — Row selection with keyboard navigation
- Scale — Scroll compression for million-row tables
- Async — Lazy data loading with placeholder rows
Examples #
- Data Table — 10K rows with resizable columns, sorting, and selection
- File Browser — Finder-like file browser with table list view and grid toggle