Spreadsheet

A virtualized, keyboard-driven editable grid: arrow keys, paste from a real spreadsheet, drag to reorder.

NameReferenceStock
<Spreadsheet.Root>
	<Spreadsheet.Columns>
		<Spreadsheet.Column />
		<Spreadsheet.Column width={130} />
	</Spreadsheet.Columns>
	<Spreadsheet.Header>
		<tr>
			<Spreadsheet.Head>Name</Spreadsheet.Head>
			<Spreadsheet.Head>Reference</Spreadsheet.Head>
		</tr>
	</Spreadsheet.Header>
	<Spreadsheet.Body>
		{rows.map((row) => (
			<Spreadsheet.Row key={row.id}>
				<Spreadsheet.Cell>
					<Input value={row.name} onChange={} />
				</Spreadsheet.Cell>
				<Spreadsheet.Cell>
					<Input value={row.reference} onChange={} />
				</Spreadsheet.Cell>
			</Spreadsheet.Row>
		))}
	</Spreadsheet.Body>
</Spreadsheet.Root>;

The grid never sorts or reorders your rows itself — it reports the intent and you reorder the array, so the data stays the single source of truth.

API

Spreadsheet.Root

PropTypeDefault
columnOrderstring[]Controlled column order, as the list of columnIds currently rendered. Providing it (with onColumnOrderChange) makes identified header cells draggable and Alt+arrow movable; the consumer re-renders columns, header and body cells alike, in the received order.
columnSizingRecord<string, number>Column widths by columnId, written by resizable header cells. Leave undefined for internal state; pass it (with onColumnSizingChange) to control or persist the widths. Any entry switches the table to table-layout: fixed.
containerClassNamestringClasses for the scroll container around the table, which is where a bounded height belongs — className goes to the <table> itself and cannot scroll.
gridNavigationbooleanOpt-in spreadsheet keyboard layer, upgrading the table to role="grid": the body cells become a single roving tab stop (arrows/Home/End/PageUp/ PageDown move, Enter/F2/typing edits, Escape comes back), Shift+arrows or pointer drag select a rectangle and Cmd/Ctrl+C copies it as TSV (the value prop on SpreadsheetCell overrides what's copied). Off by default: the phase-1 native Tab order stays the baseline.
mobileAddRowReactNodeRendered full-width under the mobile card stack - put an SpreadsheetMobileAddRow here, wired like your SpreadsheetAddRow.
onColumnOrderChange((order: string[]) => void)Called with the new columnId order once a drag or Alt+arrow move lands.
onColumnSizingChange((sizing: Record<string, number>) => void)Called on every drag frame of a resize; debounce before persisting.
onPasteData((data: SpreadsheetPasteData) => void)Grid-mode paste (Cmd/Ctrl+V on a cell in navigation mode): receives the parsed TSV matrix and the target's top-left position. The table never mutates anything - apply the values to your row array, extending it when the paste overflows (multi-row pastes from Excel/Sheets are the target use case).
onRowMove((fromIndex: number, toIndex: number) => void)Enables SpreadsheetDragHandle cells: called with the dragged row's index and its target position. Apply the move to your row array.
onSortChange((sort: SpreadsheetSort | null) => void)Called with the next sort, or null at the end of the tri-state cycle.
renderMobileRow((index: number) => ReactNode)Card content for one row below the md breakpoint. When provided (with rowCount), the table is replaced on mobile by a vertical stack of cards - a dense grid is not editable with a thumb. Render the SAME controlled fields in a labelled vertical layout (Field + regular controls, not flattened) plus the row's delete button; mobileAddRow takes over from SpreadsheetAddRow under the stack.
rowCountnumberTotal number of rows - only read by the mobile card mode.
sortSpreadsheetSort | nullControlled sort state for sortable header cells. The table never sorts the rows itself: reorder your row array when this changes (a sorted view over indexed form rows would break their binding).
stickyFirstColumnbooleanPins each row's first cell while the table pans horizontally, keeping the row label readable on narrow screens; an edge shadow appears once the container is actually scrolled. Render a read-only part (SpreadsheetCellText) as the first column - a panning grid under a pinned editable cell is disorienting.
stickyHeaderbooleanPins the header row while the body scrolls. Only useful together with a max-h-* on the container (via containerClassName).

Plus the DOM props of the element it renders. Source: spreadsheet/components/spreadsheet-root.tsx.

Sorting, resizing and reordering are all reported, never applied: the table hands you the intent and you reorder the array. A sorted view over indexed form rows would break their binding, which is why this is a rule rather than a default.

Spreadsheet.Columns

Plus every <colgroup> prop. Source: spreadsheet/components/spreadsheet-columns.tsx.

Spreadsheet.Column

PropTypeDefault
columnIdstringMatches the columnId of the header cell that resizes this column.
widthstring | numberStarting width. A resize writes over it, matched by columnId.

Plus every <col> prop. Source: spreadsheet/components/spreadsheet-column.tsx.

Widths live on <col> elements rather than header classes, so resizing and table-layout: fixed have one place to write to.

Spreadsheet.Header

Plus every <thead> prop. Source: spreadsheet/components/spreadsheet-header.tsx.

Spreadsheet.Head

PropTypeDefault
columnIdstringStable column identifier for sorting/resizing/reordering.
resizablebooleanfalseAdds a resize handle on the right edge. Needs columnId.
sortablebooleanfalseTurns the header into a tri-state sort button. Needs columnId.

Plus every <th> prop. Source: spreadsheet/components/spreadsheet-head.tsx.

Spreadsheet.Body

Plus every <tbody> prop. Source: spreadsheet/components/spreadsheet-body.tsx.

Spreadsheet.Row

PropTypeDefault
invalidbooleanDraws the invalid ring on the row's cells. The row is the right place for it: the controls inside sit on transparent backgrounds.

Plus every <tr> prop. Source: spreadsheet/components/spreadsheet-row.tsx.

Spreadsheet.Cell

PropTypeDefault
valuestringWhat gridNavigation copy serializes for this cell instead of the inner control's DOM value - required for controls whose DOM value isn't the data (Checkbox, Switch), useful whenever the copied text should differ from what the control displays.

Plus every <td> prop. Source: spreadsheet/components/spreadsheet-cell.tsx.

Spreadsheet.CellText

Plus every <td> prop. Source: spreadsheet/components/spreadsheet-cell-text.tsx.

A read-only cell. Use it for the first column when stickyFirstColumn is on — a grid panning under a pinned editable cell is disorienting.

Spreadsheet.CellImage

PropTypeDefault
onFileSelect(file: File) => voidrequiredHands over the picked File. Upload it, then feed the URL back as src.
pickLabelstringrequiredAccessible name of the picker button, e.g. "Import an image, row 3".
acceptstring"image/*"accept for the hidden file input, e.g. "image/png,image/jpeg".
altstring""Alt text of the thumbnail - the row's label, not "image".
disabledbooleanfalseBlocks all three import paths: the picker, the drop and the paste.
onRemove(() => void)Omit to make the image non-clearable (no remove affordance is drawn).
removeLabelstringAccessible name of the remove button. Only drawn when onRemove is passed.
srcstring | nullDisplayed thumbnail URL; empty or null renders the placeholder.
uploadingbooleanfalseSwaps the thumbnail for a spinner while your upload runs.
valuestring""What gridNavigation copy serializes for this cell.

Plus every <td> prop. Source: spreadsheet/components/spreadsheet-cell-image.tsx.

Imports a file three ways: click, drop onto the cell, or paste while the cell is focused. It neither uploads nor holds the file — onFileSelect hands you the File and you feed the resulting URL back as src.

Spreadsheet.DragHandle

PropTypeDefault
indexnumberrequiredThe row's current position. Needs onRowMove on the table to do anything.

Plus every <td> prop. Source: spreadsheet/components/spreadsheet-drag-handle.tsx.

Render it as the row's first cell. Keyboard: Space grabs, arrows pick the position, Space drops, Escape cancels.

Spreadsheet.RowActions

Plus every <td> prop. Source: spreadsheet/components/spreadsheet-row-actions.tsx.

Spreadsheet.AddRow

PropTypeDefault
colSpannumberrequiredMust count ALL columns, the drag-handle and actions columns included.

Plus every <button> prop. Source: spreadsheet/components/spreadsheet-add-row.tsx.

Spreadsheet.MobileAddRow

Plus every <button> prop. Source: spreadsheet/components/spreadsheet-mobile-add-row.tsx.

Spreadsheet.VirtualRows

PropTypeDefault
children(index: number) => ReactNoderequiredRenders ONE row, by absolute index. Keep your usual Spreadsheet.Row markup here.
countnumberrequiredTotal number of rows, mounted or not.
estimatedRowHeightnumber33Pixel height of one row, borders included.
overscannumber8Extra rows rendered on both sides of the visible window.

Plus the DOM props of the element it renders. Source: spreadsheet/components/spreadsheet-virtual-rows.tsx.

Worth it from about a hundred rows. It rules out row dragging — positional drop targets do not survive windowing — and needs fixed-height rows plus a bounded containerClassName.

Spreadsheet.Skeleton

PropTypeDefault
columnsnumberrequiredMust match the real column count, or the placeholder rows will not line up.
rowsnumber3How many placeholder rows to draw.

Plus the DOM props of the element it renders. Source: spreadsheet/components/spreadsheet-skeleton.tsx.

Sized to the real rows (32px + 1px) so the swap to live data shifts nothing.