Want to skip to the implementation? Check out these Lit examples:
Here's how you set up your table to use cell selection features. Adding the cell selection feature enables the related APIs.
import { LitElement, html } from 'lit'
import {
TableController,
tableFeatures,
cellSelectionFeature,
} from '@tanstack/lit-table'
const features = tableFeatures({ cellSelectionFeature })
class MyTable extends LitElement {
private tableController = new TableController<typeof features, Person>(this)
render() {
const table = this.tableController.table({
features,
columns,
data: this._data,
})
// ...
}
}The cell selection feature keeps track of spreadsheet-style rectangular selections. A user can click a cell, drag across a block of cells, Shift-click to extend, and Ctrl/Cmd-drag to add or subtract a rectangle based on whether the starting cell is selected. Let's take a look at some common use cases.
The table instance already manages the cell selection state for you. You can access the selection or values derived from it through a few APIs.
console.log(table.state.cellSelection) //get the cell selection state
console.log(table.getSelectedCellCount()) //3
console.log(table.getSelectedCellIds()) //['0_firstName', '0_lastName', '1_firstName']
console.log(table.getSelectedCellRangesData()) //[[['Tanner', 'Linsley'], ['Kevin', 'Vandy']]]In event handlers or other non-render code, you can also read the current snapshot with table.atoms.cellSelection.get(). This read does not subscribe a component to future changes, so prefer table.state.cellSelection in render positions.
The expansion APIs (getSelectedCellIds, getSelectedCellRangesData) are memoized and pull-based. They cost nothing unless you actually call them, so a table that only highlights cells never pays to enumerate a large selection.
CellSelectionState is an ordered array of range operations, each stored as its two defining corners:
type CellSelectionRange = {
anchorRowId: string
anchorColumnId: string
focusRowId: string
focusColumnId: string
operation?: 'include' | 'exclude'
}
type CellSelectionState = Array<CellSelectionRange>The anchor corner is where the selection started and stays put. The focus corner is the one that moves while dragging or Shift-extending. Storing both corners, rather than a normalized min/max rectangle, is what makes Shift-extend and "collapse back to the active cell" possible.
Ranges are applied in order. An omitted operation is an inclusion for backward compatibility; an exclude range subtracts its rectangle from the selection produced so far. This compact operation log means a “select all except these cells” interaction does not build a map with one entry per selected cell.
If you need access to the selection elsewhere in your application, you can own the state slice yourself. The recommended way in v9 is an external atom passed through the atoms table option.
import { createAtom } from '@tanstack/lit-store'
import {
TableController,
tableFeatures,
cellSelectionFeature,
type CellSelectionState,
} from '@tanstack/lit-table'
const features = tableFeatures({ cellSelectionFeature })
const cellSelectionAtom = createAtom<CellSelectionState>([])
const table = this.tableController.table({
features,
columns,
data: this._data,
atoms: { cellSelection: cellSelectionAtom },
})The classic controlled-state pattern also works:
const table = this.tableController.table({
features,
columns,
data: this._data,
state: { cellSelection: this._cellSelection },
onCellSelectionChange: (updater) => {
this._cellSelection =
typeof updater === 'function' ? updater(this._cellSelection) : updater
},
})Note: a drag emits one change per cell boundary the pointer crosses, so onCellSelectionChange fires repeatedly during a drag. If you are syncing selection to a server or a URL, debounce it or commit on mouseup.
Cell selection is keyed by row id and column id, so a meaningful row id matters here for the same reason it does with row selection. Use the getRowId table option to key selection by something stable from your data.
const table = this.tableController.table({
features,
//...
getRowId: (row) => row.uuid, // use the row's uuid from your database as the row id
})Cell selection is enabled by default for every cell. Use the enableCellSelection table option to turn it off entirely, or pass a function for per-cell control.
const table = this.tableController.table({
features,
//...
enableCellSelection: (cell) => cell.row.original.age > 18, //only adults' cells are selectable
})A column def can also opt out, which is the common case for checkbox or action columns. A column-level false wins over the table option.
columnHelper.accessor('actions', {
enableCellSelection: false, //this column can never be selected
})A cell that cannot be selected is skipped even when a rectangle is drawn straight through it, and moveCellSelection steps over its column rather than landing on it. Use cell.getCanSelect() to decide whether to attach selection handlers in your UI.
Two cell handlers drive every mouse interaction:
html`<td
@mousedown=${cell.getSelectionStartHandler()}
@mouseenter=${cell.getSelectionExtendHandler()}
>
${FlexRender({ cell })}
</td>`You do not need to handle mouseup yourself. The start handler attaches its own document-level mouseup listener and removes it when the drag ends, so releasing the pointer outside the table still finishes the drag correctly. If your table renders into another document, such as an iframe or a popout window, pass that document in: cell.getSelectionStartHandler(myDocument).
Pressing down on a cell starts a new single-cell range, and every cell the pointer then enters moves that range's focus corner. Set enableCellSelectionDrag: false to require explicit clicks instead.
Shift-clicking moves the active range's focus corner to the clicked cell, keeping its anchor fixed. The active cell therefore stays where the selection started, matching spreadsheet behavior.
The handler recognizes Shift when the event exposes either event.shiftKey or event.nativeEvent.shiftKey. You can disable range behavior or replace the detection:
const table = this.tableController.table({
features,
//...
enableCellRangeSelection: false,
// For example, use the platform modifier instead of Shift:
// isCellRangeSelectionEvent: event => Boolean(event.metaKey),
})Ctrl-clicking or Cmd-clicking an unselected cell adds a new inclusive rectangle. Starting the same modified interaction on a selected cell adds an exclusion instead, so clicking removes that cell and dragging subtracts the whole rectangle. Whether the drag includes or excludes is fixed when it starts; shrinking an exclusion drag restores cells that leave its rectangle. Set enableMultiCellRangeSelection: false to disable both behaviors, or override isMultiCellRangeSelectionEvent to change the modifier.
table.selectCellRange(range) replaces the current selection. Pass { mode: 'include' } to append an inclusion or { mode: 'exclude' } to append an exclusion. The older { additive: true } option remains as a deprecated alias for include mode; mode wins if both options are supplied. table.getCellSelectionBounds() resolves the operation log into deterministic, disjoint positive rectangles.
TanStack Table does not dictate how you render selected cells. These cell APIs give you everything you need:
getSelectionEdges() returns { top, right, bottom, left }, where a side is true when the neighboring cell in that direction is not itself selected. That is what lets you draw a single continuous outline around a selection, including around a union of separate rectangles, without every cell inspecting its neighbors.
function getCellClassName(cell) {
// most cells are unselected, so bail before asking for edges
if (!cell.getIsSelected()) {
return cell.getIsFocused() ? 'cell cell-focused' : 'cell'
}
const edges = cell.getSelectionEdges()
return [
'cell',
'cell-selected',
cell.getIsFocused() && 'cell-focused',
edges.top && 'cell-edge-top',
edges.right && 'cell-edge-right',
edges.bottom && 'cell-edge-bottom',
edges.left && 'cell-edge-left',
]
.filter(Boolean)
.join(' ')
}Tip: draw the outline with box-shadow: inset ... rather than border. On a border-collapse table a thicker border widens the shared grid line, which makes rows change height as cells become selected. A box-shadow never affects layout.
Cell selection ships no keyboard handling of its own. Instead it exposes imperative APIs so a dedicated library, such as TanStack Hotkeys, can drive it:
direction is 'up', 'down', 'left', or 'right'.
import { createMultiHotkeyHandler } from '@tanstack/lit-hotkeys'
// one stateless handler is simpler here than a HotkeyController per binding
const onGridKeyDown = createMultiHotkeyHandler({
ArrowUp: () => table.moveCellSelection('up'),
ArrowDown: () => table.moveCellSelection('down'),
'Shift+ArrowDown': () => table.extendCellSelection('down'),
'Mod+A': () => table.selectAllCells(),
Escape: () => table.resetCellSelection(true),
})
// then, in the template:
// html`<div tabindex="0" @keydown=${onGridKeyDown}> ... </div>`Scope the hotkeys to the grid element rather than the document, or arrow keys and Escape will hijack inputs elsewhere on the page.
getSelectedCellRangesData() returns raw values indexed as [regionIndex][rowIndex][columnIndex]. A region is one of the final disjoint positive rectangles after all include and exclude operations are applied, so it does not necessarily correspond one-to-one with stored state. Turning that into clipboard text is left to your application, because the delimiter, the representation of null, and any quoting rules are decisions only you can make.
function escapeTsvValue(value: unknown) {
const text = value == null ? '' : String(value)
const safeText =
typeof value === 'string' && /^[\t\r ]*[=+@-]/.test(value)
? `'${text}`
: text
// spreadsheets expect a quoted field once it contains a delimiter, a newline,
// or a quote, with inner quotes doubled
return /["\t\n\r]/.test(safeText)
? `"${safeText.replace(/"/g, '""')}"`
: safeText
}
function toTsv(ranges: Array<Array<Array<unknown>>>) {
return ranges
.map((grid) =>
grid.map((row) => row.map(escapeTsvValue).join('\t')).join('\n'),
)
.join('\n\n')
}
navigator.clipboard.writeText(toTsv(table.getSelectedCellRangesData()))Ranges store row and column ids, not positions, so they follow their corner cells rather than screen coordinates.
Because a reorder can widen a selection onto columns the user never picked, some applications prefer to clear the selection whenever the column layout changes. That is a userland decision; Lit's updated lifecycle can implement it:
private _lastLayoutKey: string | undefined
protected updated() {
const layoutKey = JSON.stringify([
this.table.atoms.columnOrder.get(),
this.table.atoms.columnPinning.get(),
this.table.atoms.columnVisibility.get(),
])
if (this._lastLayoutKey === undefined) {
this._lastLayoutKey = layoutKey
} else if (layoutKey !== this._lastLayoutKey) {
this._lastLayoutKey = layoutKey
queueMicrotask(() => this.table.resetCellSelection(true))
}
}table.resetCellSelection() restores initialState.cellSelection. Pass true to ignore initial state and clear the selection entirely.
The selection also resets automatically whenever data changes, because new data can invalidate the row ids a range points at, or silently re-select cells if the new data happens to reuse ids. Turn that off with autoResetCellSelection: false, and note that autoResetAll overrides it.
const table = this.tableController.table({
features,
//...
autoResetCellSelection: false, //keep ranges across data changes
})The table controller requests a re-render on selection changes and lit-html patches only the bindings that actually changed, so the example renders its cells plainly.
Measured on a table with a thousand rows and twelve columns, a drag updates in roughly 13ms per move.
The subscribe directive is available if you want an explicit fine-grained subscription for one part of a template:
import { subscribe } from '@tanstack/lit-table'
html`${subscribe(
table.atoms.cellSelection,
(ranges) => ranges.length,
(count) => html`<span>${count} ranges</span>`,
)}`Note that Lit renders into shadow DOM, so the selection classes must be defined in the component's static styles rather than a global stylesheet.