Skip to main content

Table

import { Table } from '@react-x11/components/table';

<Table
rows={files}
columns={[
{ id: 'name', label: 'Name', flex: 1 },
{
id: 'size',
label: 'Size',
width: 96,
align: 'end',
value: (f) => f.bytes,
},
{
id: 'modified',
label: 'Modified',
width: 160,
value: (f) => f.mtime.toLocaleString(),
},
]}
onActivate={(id, file) => open(file)}
style={{ flexGrow: 1, minHeight: 0 }}
/>;

A data table: a header that stays put, sort on header click, selection on row click, resizable columns, and only the rows in view actually built. It registers no host element — a table is <box>, <text> and core's chevron, with no side effect at import time.

<Table> is a successor to react-x11's own <Table>, the way the Tree succeeds core's tree: nothing here imports it, the two share no code, and the prop names core call sites already use (columns, rows, sort, selected, onActivate, onColumnResize) mean migrating is changing the import. The design record, including the prior-art survey and the continuity contract the API is built around, is the PRD.

Columns

Columns are data, not children — there is no HTML table layout algorithm here, so the pixel arithmetic is this component's, and it resolves once, at the table level: every row agrees on the grid by construction, and a virtualized row mounting late cannot re-negotiate it.

FieldTypeNotes
idstringNames the column, the sort, and — by default — the row property the cell shows.
labelstringThe header caption. Defaults to the id.
widthnumberA fixed column, in pixels. Ignored when flex is set.
flexnumberShares the viewport width left over after the fixed columns, by weight. A column declaring neither width nor flex is flex: 1 with a 120px floor — a table with no sizing config fills its box.
minWidthnumberThe floor for flex resolution and user resize. Defaults to 40; 120 for an unsized column.
align'start' | 'center' | 'end'Logical: 'end' is the edge the row finishes at — where a column of figures lines up — and the left edge under RTL, with nothing else said.
sortablebooleanWhether the header click sorts here. Default true.
value(row) => unknownFeeds the default cell text and the sort. Defaults to row[id].
compare(a, b) => numberSort order over whole rows. Defaults to a natural comparison over value() — numbers numerically, everything else as text.
render(row, state) => ReactNodeReplaces the cell — see Seams.
renderHeader(state) => ReactNodeReplaces the header cell's content — the box, its width, the sort click and the grip stay the table's.

Dragging a grip (or Left/Right on a focused grip, 16px a press) resizes the column and converts it to fixed at that width, reported through onColumnResize(id, width) — the behaviour every desktop file manager has taught.

Props

PropTypeNotes
columnsreadonly TableColumn[]The grid. Empty renders an empty header strip.
rowsreadonly Row[]Any shape. Each row must resolve an id — see getId.
getId(row) => TableRowIdDefaults to row.id, and a row that resolves none is a remedial TypeError naming the fix. Memoize it.
sort / defaultSort / onSortChangeTableSort | nullThe sort descriptor, controlled or uncontrolled. TableSort is { column, direction: 'asc' | 'desc' }.
presortedbooleanThe rows already arrive in display order; the table renders the indicator and reports toggles, nothing more. Composes with either descriptor mode.
selectionMode'single' | 'multiple' | 'none'Default 'single'. The selection props change shape with it — see Selection.
selected / defaultSelectedone id, or an arrayTableRowId | null in single mode, readonly TableRowId[] in multiple. The wrong pairing throws.
onSelect(id, row) => voidSingle mode.
onSelectedChange(ids, change) => voidMultiple mode; change names the gesture (replace / toggle / range / all) and the row it landed on.
onActivate(id, row) => voidThe open gesture on top of selection — a double click, or Enter.
onRowContextMenu(id, row, ev) => voidRight-click. The row is selected first unless it is already part of the selection, so a menu always applies to what is under the pointer.
onColumnResize(id, width) => voidA grip was dragged or keyed.
rowHeightnumberDeclares every row exactly this tall — core's model, nothing measured. Omit it and rows are measured instead. rowHeight={24} is core's exact behaviour.
estimatedRowHeightnumberWhat an unmeasured row is assumed — and floored — at, while measuring. Default 24. Once enough rows are measured, the guess is re-learnt from their mean.
virtualboolean | 'auto'Build only the rows on screen. 'auto' (the default) turns it on past 200 rows.
overscannumberRows built either side of the viewport. Default 6.
prefetchnumberRows built beyond the overscan while the table sits idle, per side. Default 40; 0 turns the band off. See "What a scroll costs".
renderScrollHint(state) => ReactNodeThe fast-scroll overlay. Default: a centred pill reading "2,345 / 100,000" while placeholders cover the viewport; return null for none. See below.
scrollHintDelaynumberHow long the viewport must have shown unresolved content before the overlay appears, ms. Default 250; 0 shows it the moment a catch-up engages.
catchup{ threshold?, burst?, settle? }Catch-up pacing, in rows: what counts as a flood (default 16), and full rows built per render mid-scroll / after it (defaults 24 / 48). See below.
renderRow / renderEmptySeams — see below.
stylesTableStylesPer-part style overrides; row/cell entries may be functions of row state.
focusablebooleanWhether the table is a tab stop. Default true; false for a table inside a popup that owns the focus.
styleStylePropThe root box. Width, flexGrow and minHeight: 0 go here.
refRef<TableHandle>The imperative side — see below.
data-testnamestringFor react-x11/test's queries.

Everything else a <box> takes passes through to the root — aria-label names the table.

Sorting is split in two

Who holds the descriptor and who orders the rows are separate questions, because conflating them is the classic table-library cliff: the day sort state moves into app state must not be the day the app inherits comparator duty.

  • Descriptor: sort / defaultSort / onSortChange. Lifting it changes nothing about who sorts.
  • Ordering: the table's, by default, in both descriptor modes. Pass presorted when a server already ordered the rows.

The header click cycles asc ↔ desc and never emits null; null stays in the type as the controlled "cleared" state. Uncontrolled sorting is one Array.prototype.sort per toggle — fine into the hundreds of thousands, and presorted is the documented path beyond that.

Selection

Three modes, and the props are a discriminated union on selectionMode — one id and an array of ids are different shapes, and the compiler walking a caller through that change is the point. The wrong pairing at run time is a remedial TypeError, not an empty render.

  • 'single' (default, core's behaviour): selected / defaultSelected / onSelect(id, row). Click selects; the selection is the keyboard cursor.
  • 'multiple': selected as an array, onSelectedChange(ids, change). The pointer grammar is the file manager's: click replaces, Ctrl+click toggles, Shift+click extends from the anchor — and a Ctrl toggle moves the anchor with it. The cursor and anchor are ids, not indexes: a re-sort moves the rows, not the user's place.
  • 'none': display only. No cursor, no hover, no aria-selected.

Keyboard

The table is a single tab stop; the focus is on the table, never the row.

KeyWhat it does
Down / UpMove the selection (multiple: the cursor; Shift extends from the anchor)
Home / EndFirst / last row (Shift extends)
PageDown / PageUpOne viewport, minus a row of overlap (Shift extends)
EnterActivate the cursor row (onActivate)
SpaceMultiple: toggle the cursor row. Single: activate
Ctrl+AMultiple: select everything
Left / Right on a focused gripResize that column by 16px

Arrows the table takes are preventDefaulted — the root is focused above a scroll container with default key actions of its own, and without that every arrow both moved the selection and scrolled the list under it. The keyboard scrolls only when the selection would leave the viewport. A key the table did not take keeps its default, which is also what TableHandle.handleKey reports to a control that forwards keys down.

Two height models, one prop

  • rowHeight={n} — declared uniform. Every row is exactly n tall, content clipped to one line, and the visible slice is arithmetic: nothing is ever measured. Core's model, and the fast path for the ordinary text-rows table.
  • rowHeight omitted — measured. Rows grow to their content — a render seam may wrap text or stack lines — and the tree's height index reads back what each drawn row became. estimatedRowHeight is what the scrollbar assumes for rows not yet seen (and the floor a row cannot shrink under). The guess converges as you scroll — and once enough rows have real numbers, the guess itself is re-learnt from their mean, so the scrollbar lands near the truth without the whole list ever being visited. Id-keying means a re-sort permutes offsets without remeasuring a single row.

virtual="auto" (the default) starts windowing past 200 rows; below that every row is real. The honest caveat, the same one the tree carries: while virtualizing, only the rendered rows are in the accessibility tree — the same rows a sighted user can see. virtual={false} keeps a table whole regardless, for a screenshot or a test that wants every row.

What a scroll costs, and what is done about it

The renderer blits a scroll and repaints the exposed strip before React runs, so whatever is mounted in that strip is what the user sees; rows built by the re-render land a frame later at the earliest. The only scroll with no blank frame at all is one that lands on rows already built, and the virtualization window works toward exactly that:

  • Idle prefetch. While nothing is scrolling, the window grows in small steps past the overscan, up to prefetch rows each side — prep work done while nobody is watching, so the next notch lands on rows that are already there. In the measured model the idle band also measures as it grows, which is what feeds the estimate re-learning above.
  • Velocity lead. While scrolling, the window extends in the direction of travel by roughly where the scroll will be a few frames on.
  • A kept band. Rows already built stay mounted until a budget (the slice plus prefetch per side) forces them out, trailing side first — a direction reversal lands on rows still there.
  • Skeleton rows. A scroll that outruns everything — a thumb dragged across the list — floods the window with more rows than one render can build in time (catchup.threshold, default 16 entering rows; the build pace is catchup.burst / catchup.settle rows per render — raise the threshold past the window size to never see skeletons, or shrink the budgets for tables whose render seams are expensive). Those commit as skeletons first: the row box at its indexed height with styles.row applied and none of its content, so what blits in reads as rows arriving rather than a void. They are aria-hidden, and they fill in viewport-first over the next few ticks.

None of this needs configuring; prefetch={0} turns the band off and returns the slice to exactly viewport-plus-overscan.

Laying one out

The same rule as the tree, and the same symptom when it is missed: every flex ancestor between the window and the table needs minHeight: 0, or the ancestor grows to hold every row and the scroll pane inside it has nothing left to scroll. The table's own root carries flexGrow: 1, minHeight: 0 and minWidth: 0; give the box you put it in the same treatment.

The header strip is not part of the scrolled content — it sits above the pane and tracks the body's horizontal scroll by marginStart, so it follows the direction the columns run under RTL and never moves vertically.

Seams

Every visible part is replaceable without giving up what the table manages — geometry, selection, the keyboard, virtualization.

column.render

{
id: 'status',
render: (file, state) => (
<box style={{ flexDirection: 'row', gap: 4 }}>
<Icon name="dot" size={8} color={file.dirty ? '#e17055' : state.color} />
<text style={{ color: state.color }}>{file.status}</text>
</box>
),
}

The state says whether the cell is on the selected row and carries color, the row's resolved ink — the selection is a filled bar, and a colour picked against the resting background is unreadable on it. Colour does not cascade into an <Icon> or a <canvas>; hand it over.

renderRow

Wraps a row's content, given what would have been there. The row box stays the table's — it carries the height virtualization counts on, the role and aria the table is read through, and the click that selects.

renderEmpty

The body when the rows resolve empty. Nothing by default; the header still shows.

styles

{ header, headerCell, row, cell } — the row and cell entries may be functions of the row/cell state, called per rendered row, so a style can follow state without a render prop that repaints everything. Zebra striping is the recipe:

<Table

styles={{ row: (s) => s.index % 2 === 1 && { backgroundColor: '#00000010' } }}
/>

There is deliberately no striped prop: the palette has no alternate-row token, and a boolean that painted surfaceHover would collide with hover itself. One line of styles.row says exactly what you mean.

TableHandle

interface TableHandle<Row> {
focus(): void;
select(id: TableRowId | null): void; // null clears an uncontrolled selection
scrollToRow(id: TableRowId): boolean; // false: unknown id
handleKey(ev: KeyboardEvent): boolean; // a filter box above forwards arrows down
rows(): readonly TableRow<Row>[]; // the sorted model, not the rendered slice
}

Following a live tail

The pattern a log, a console or a packet trace wants — rows arrive, and the newest one stays in view — is scrollToRow in an effect on the rows:

useEffect(() => {
const last = rows.at(-1);
if (last) table.current?.scrollToRow(last.id);
}, [rows]);

That row does not exist on screen yet when you ask for it, and the scroll pane can only scroll as far as the content it has already laid out, so the request is kept and finished on the layout that admits the new rows — however many updates arrive between two frames, and whether the newest row was appended, or arrived as the whole list replaced. A scroll of the user's own drops the request rather than fighting it: reaching for the wheel stops the tail, and the next update's scrollToRow starts it again — so an app that wants "follow unless the user has scrolled away" decides that itself, by not calling scrollToRow.

The row model is importable on its own — orderRows, resolveWidths, columnValue, defaultCompare, resolveGetId from @react-x11/components/table — for an app that does the same arithmetic outside the component (a status bar totalling a column, a test asserting order).

Decisions

  • Multiple selection exists here, where the tree refused it. The tree's reasons do not apply to a flat list: there is no branch for Ctrl+click to ambiguously take, and a Shift range over sorted order has exactly one meaning. The grammar shipped is the one every desktop file manager agrees on, anchor semantics included.
  • A right-click selects the row under it unless that row is already part of the selection — a menu over "the selected files" must not collapse them. Only the left button drives the click-to-select path.
  • The sort click never emits null. Core's type admitted it and never produced it; the successor keeps the toggle and documents the type as the controlled clearing path, rather than inventing a third click state.
  • Unsized columns stretch. Core parked dead space to the right of fixed-120 columns; here an unsized column is flex: 1 floored at 120. Declaring width restores core's behaviour per column.
  • estimatedRowHeight is both the guess and the floor while measuring. One knob moves both, and an estimate that is also a floor can never over-report a row it has already seen.
  • scrollToRow outlives the frame it was asked in, and the visible slice is re-read from the pane after every layout rather than trusted to onScroll. Both exist because a scroll pane moves without saying so — it clamps an offset the content has outgrown, and resolves a queued reveal — and a virtualizer that believes the last event it heard draws rows where the viewport no longer is. See Following a live tail.

Migrating from core's <Table>

Change the import. Then, only if it matters:

  • Rows still need an id — or pass getId, which core did not have.
  • align speaks logical start/end where core said left/right — the same rendered result in LTR, and RTL mirrors without a second prop.
  • Core's fixed 24px rows are rowHeight={24}; omitting it measures instead.
  • Columns without width now stretch rather than sitting at 120 fixed.
  • Tables under 200 rows are no longer virtualized (better for the accessibility tree); virtual forces it either way.

Example

npm run examples:table — a process monitor over the real ps output: zero-config columns on the left, and the same table grown rung by rung — custom cells, multiple selection, sorting, a hundred thousand measured rows — on the right. It needs a real $DISPLAY.