Cell selection and clipboard
Click a cell and it takes the focus; drag, Shift-click or Shift+arrows and you have a range; ⌘C puts the range on the clipboard as tab-separated text that pastes straight into a spreadsheet. It is on by default and costs nothing when unused.
| Olivia Prabhakaran | Data | Active | Free | 1 | $0.00 | |
| Amelia Reddy | Design | Active | Pro | 4 | $48.00 | |
| Kavya Menon | Finance | Invited | Pro | 1 | $12.00 | |
| Saanvi Das | Platform | Paused | Free | 1 | $0.00 | |
| Meera Menon | Growth | Active | Free | 1 | $0.00 | |
| Liam Bose | Platform | Invited | Free | 1 | $0.00 | |
| Saanvi Sharma | Finance | Active | Team | 30 | $870.00 | |
| Noah Garcia | Research | Active | Team | 62 | $1,798.00 |
import * as React from "react";import { ClipboardCopyIcon } from "lucide-react";
import { Button } from "@/components/ui/button";import { DataGrid, copyText, createDataGridColumns, selectedCellsToTsv, useDataGrid,} from "@/components/ui/data-grid";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();
const columns = col.columns([ col.select(), col.text("name", { header: "Name" }), col.text("team", { header: "Team" }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS }), col.number("seats", { header: "Seats" }), col.currency("mrr", { header: "MRR", currency: "USD" }),]);
const data = makePeople(60, 27);
/** Spreadsheet-style ranges: drag, Shift-click, ⌘-click, then copy as TSV. */export default function DataGridCellSelection() { const grid = useDataGrid<Person>({ key: "cell-selection", data, columns, getRowId: (r) => r.id, pagination: { pageSize: 8 }, }); const [copied, setCopied] = React.useState(0); // `table.state` is reactive, so this component re-renders as the selection changes. const count = grid.table.state.cellSelection.length ? grid.table.getSelectedCellCount() : 0;
const copy = async () => { const { text, count: n } = selectedCellsToTsv(grid); if (n && (await copyText(text))) setCopied(n); };
return ( <DataGrid grid={grid} toolbarActions={ <> <span className="text-sm text-muted-foreground tabular-nums"> {count ? `${count} ${count === 1 ? "cell" : "cells"} selected` : copied ? `Copied ${copied}` : "Drag across cells"} </span> <Button variant="outline" size="sm" disabled={!count} onClick={copy}> <ClipboardCopyIcon /> Copy </Button> </> } /> );}Use it
Section titled “Use it”Nothing to add: cellSelection defaults to true. Turn it off with cellSelection: false, which
also removes the grid’s keyboard model for the body (arrows, Enter to
edit, Space to select) and re-enables native text selection.
<DataGrid data={rows} columns={columns} cellSelection={false} />To keep a single column out of ranges, set TanStack’s column option: col.text("secret", { enableCellSelection: false }).
With the pointer
Section titled “With the pointer”| Gesture | Result |
|---|---|
| Click | Focuses the cell and collapses any range to it. |
| Drag | Extends the range from the cell you pressed on, cell by cell, until you release (even outside the grid). |
| Shift-click | Extends the active range to the clicked cell, keeping its anchor. |
| ⌘-click or Ctrl-click | Starts a second range. On a cell that is already selected it starts an exclusion instead, cutting a hole in the selection. Dragging continues the same operation. |
The modifiers are TanStack Table’s defaults: shiftKey extends, ctrlKey or metaKey adds or
subtracts. The grid passes enableCellRangeSelection, enableMultiCellRangeSelection and
enableCellSelectionDrag as true; change any of them, or the predicates, through the
table escape hatch:
useDataGrid({ data, columns, table: { enableMultiCellRangeSelection: false } })With the keyboard
Section titled “With the keyboard”| Keys | Result |
|---|---|
| ↑ ↓ ← → | Move the focused cell (and collapse the range to it). With nothing focused, the first key focuses the first cell. |
| Shift+arrows | Extend the active range one cell. |
| ⌘A | Select every selectable cell of every row (all pages, not just the current one). |
| ⌘C | Copy the selection as TSV. |
| Esc | Collapse a range back to the focused cell. |
⌘ is Ctrl on Windows and Linux. The full table, including Home, End and PageDown, is on the keyboard page.
What you see
Section titled “What you see”- The focused cell has an inset ring in the primary colour (
ring-2 ring-primary ring-inset) and is the one element in the body withtabindex="0"(roving tabindex). - Selected cells are tinted
bg-primary/10, and the outer edges of the selection get a one-pixel inset line, so a union of rectangles reads as one outline. - Cells carry
data-focusedanddata-selectedwhile in those states, for your own styles. - Text selection is disabled on the table (
select-none) so a drag selects cells, not characters.
Copying
Section titled “Copying”⌘C with a selection calls selectedCellsToTsv(grid) and copies the result:
- rows in display order, columns in visible order, one line per row, a tab between cells,
\nbetween lines, no header row; - values formatted the way the cells show them (currency, dates, enum labels), using the same
formatValueas the cells and the export; blanks are empty strings; - a cell inside the bounding box but outside the selection (after a ⌘-click exclusion) is an empty field, so the pasted shape stays rectangular.
The copy uses navigator.clipboard.writeText, falling back to document.execCommand("copy") on
plain http:// origins and in some webviews. On success the live region announces
labels.copied(count) (“Copied 4 cells”).
Copy with headers, or copy whole rows, from the Export menu instead.
How it behaves
Section titled “How it behaves”- The checkbox, expander, row-number and actions columns never join a range, a select-all or a copy.
- A range is stored as two corner cells by row id and column id, not as positions. Sorting, filtering or reordering keeps the corners and recomputes what lies between them; hiding a column a corner sits on makes the range inert until the column returns.
- The selection resets whenever
datachanges (TanStack’sautoResetCellSelection), so a refetch does not leave ranges pointing at rows that moved. Keep ranges across refetches withtable: { autoResetCellSelection: false }. - While an editor is open, pointer selection on other cells and the body’s keys are paused.
- Row selection (the checkbox column) is a separate feature; Space toggles the focused row and bulk actions work on rows, not cells.
Options
Section titled “Options”| Option | Type | Default | What it does |
|---|---|---|---|
cellSelection |
boolean |
true |
Cell focus, ranges, copy and the body keyboard model. |
enableCellSelection (column) |
boolean |
true |
TanStack column option; false keeps a column out of ranges. |
| Member | What it does |
|---|---|
grid.cellSelectionEnabled |
The resolved option. |
grid.table.getFocusedCell() |
The focused DataGridCell, or undefined. |
grid.table.setFocusedCell(rowId, columnId) |
Focus one cell and collapse the range. |
grid.table.moveCellSelection(dir) / extendCellSelection(dir) |
What the arrow keys call; dir is "up" | "down" | "left" | "right". |
grid.table.selectCellRange(range, { mode }) |
Add, replace or exclude a rectangle. |
grid.table.selectAllCells() / resetCellSelection(true) |
What ⌘A and Esc call. |
grid.table.getSelectedCellCount() |
How many cells are selected. |
grid.table.getCellSelectionRowIds() / getCellSelectionColumnIds() |
The rows and columns the selection touches. |
grid.table.getSelectedCellRangesData() |
Raw values per range, [range][row][column]. |
grid.table.state.cellSelection |
The ranges themselves (reactive). |
selectedCellsToTsv(grid) |
{ text, count }: what ⌘C copies. |
copyText(text) |
The clipboard write with its fallback; resolves true on success. |
findCellElement(root, cellId) |
The <td> for a cell id inside the grid. |
See also
Section titled “See also”- Keyboard navigation
- Export: CSV, JSON and copying whole rows with headers.
- Row selection and bulk actions