Docs
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
CodeRabbit
Cloudflare
AG Grid
SerpAPI
Netlify
OpenRouter
Neon
WorkOS
Clerk
Electric
PowerSync
Sentry
Railway
Prisma
Strapi
Unkey
Table API Reference
Column API Reference
Row API Reference
Cell API Reference
Header API Reference
Features API Reference
Static Functions API Reference
Legacy API Reference

React Example: Cell Selection

import React from 'react'
import { faker } from '@faker-js/faker'
import { TanStackDevtools } from '@tanstack/react-devtools'
import ReactDOM from 'react-dom/client'
import {
  cellSelectionFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createSortedRowModel,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
  useTable,
} from '@tanstack/react-table'
import {
  tableDevtoolsPlugin,
  useTanStackTableDevtools,
} from '@tanstack/react-table-devtools'
import { useHotkeys } from '@tanstack/react-hotkeys'
import { useCreateAtom } from '@tanstack/react-store'
import { makeData } from './makeData'
import type { Person } from './makeData'
import type {
  Cell,
  CellSelectionBounds,
  CellSelectionState,
} from '@tanstack/react-table'
import './index.css'

const features = tableFeatures({
  cellSelectionFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnVisibilityFeature,
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: {
    alphanumeric: sortFn_alphanumeric,
    datetime: sortFn_datetime,
    text: sortFn_text,
  },
})

const columnHelper = createColumnHelper<typeof features, Person>()

// Serializing a selection for the clipboard is a userland concern: the table
// hands back `getSelectedCellRangesData()` as raw values, and the delimiter,
// the null representation, and the quoting rules are all yours to pick. This is
// the spreadsheet-flavored version.
function escapeTsvValue(value: unknown) {
  const text = value == null ? '' : String(value)
  const safeText =
    typeof value === 'string' && /^[\t\r ]*[=+@-]/.test(value)
      ? `'${text}`
      : text

  // spreadsheets expect a field to be quoted 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') // blank line between final selected regions
}

function App() {
  const columns = React.useMemo(
    () =>
      columnHelper.columns([
        columnHelper.accessor('firstName', {
          header: 'First Name',
          cell: (info) => info.getValue(),
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor((row) => row.lastName, {
          id: 'lastName',
          header: () => <span>Last Name</span>,
          cell: (info) => info.getValue(),
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('age', {
          header: () => 'Age',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('visits', {
          header: () => <span>Visits</span>,
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('status', {
          header: 'Status',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('progress', {
          header: 'Profile Progress',
          // enableCellSelection: false, // this column opts out of cell selection
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('email', {
          header: 'Email',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('phone', {
          header: 'Phone',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('city', {
          header: 'City',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('country', {
          header: 'Country',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('department', {
          header: 'Department',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('salary', {
          header: 'Salary',
          cell: (info) => info.getValue().toLocaleString(),
          footer: (props) => props.column.id,
        }),
      ]),
    [],
  )

  const [data, setData] = React.useState(() => makeData(20))
  const refreshData = () => setData(makeData(20))
  const stressTest = () => setData(makeData(1_000))

  // optionally, raise the selection state to your own atom
  const cellSelectionAtom = useCreateAtom<CellSelectionState>([])

  const table = useTable(
    {
      key: 'cell-selection', // needed for devtools
      features,
      atoms: {
        cellSelection: cellSelectionAtom,
      },
      columns,
      data,
      getRowId: (row) => row.id,
      enableCellSelection: true, // enable cell selection for all cells
      // initialState: { cellSelection: [] }, // select cells on first render
      // state: { cellSelection }, // classic controlled state; pair with onCellSelectionChange
      // onCellSelectionChange: setCellSelection,
      // enableCellRangeSelection: false, // disable Shift-click and drag ranges; default true
      // enableMultiCellRangeSelection: false, // allow only one rectangle at a time; default true
      // enableCellSelectionDrag: false, // disable drag-to-select; default true
      // isCellRangeSelectionEvent: event => Boolean(event.metaKey), // use Meta instead of Shift
      debugTable: true,
    },
    // Every slice that changes the table's structure has to be here, or the
    // headers and controls will not re-render when you pin, hide, or reorder.
    // cellSelection is the deliberate exception: it is read through <Subscribe>
    // instead, so a drag does not re-render the whole example.
    (state) => ({
      sorting: state.sorting,
      columnVisibility: state.columnVisibility,
      columnOrder: state.columnOrder,
      columnPinning: state.columnPinning,
    }),
  )

  useTanStackTableDevtools(table)

  const randomizeColumns = () => {
    table.setColumnOrder(
      faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
    )
  }

  // optionally, reset the cellSelection state not only when data changes, but also when column order, pinning, visibility, or sorting changes
  // customize this to your needs
  const isFirstColumnLayout = React.useRef(true)
  React.useEffect(() => {
    if (isFirstColumnLayout.current) {
      isFirstColumnLayout.current = false
      return
    }
    table.resetCellSelection(true)
  }, [
    table.state.columnOrder,
    table.state.columnPinning,
    table.state.columnVisibility,
    table.state.sorting,
  ])

  const gridRef = React.useRef<HTMLDivElement>(null)

  // keyboard navigation is TanStack Hotkeys driving the table's imperative APIs;
  // table-core ships no keydown handling of its own
  useHotkeys(
    [
      { hotkey: 'ArrowUp', callback: () => table.moveCellSelection('up') },
      { hotkey: 'ArrowDown', callback: () => table.moveCellSelection('down') },
      { hotkey: 'ArrowLeft', callback: () => table.moveCellSelection('left') },
      {
        hotkey: 'ArrowRight',
        callback: () => table.moveCellSelection('right'),
      },
      {
        hotkey: 'Shift+ArrowUp',
        callback: () => table.extendCellSelection('up'),
      },
      {
        hotkey: 'Shift+ArrowDown',
        callback: () => table.extendCellSelection('down'),
      },
      {
        hotkey: 'Shift+ArrowLeft',
        callback: () => table.extendCellSelection('left'),
      },
      {
        hotkey: 'Shift+ArrowRight',
        callback: () => table.extendCellSelection('right'),
      },
      { hotkey: 'Mod+A', callback: () => table.selectAllCells() },
      { hotkey: 'Escape', callback: () => table.resetCellSelection(true) },
      {
        hotkey: 'Mod+C',
        callback: () => {
          void navigator.clipboard.writeText(
            toTsv(table.getSelectedCellRangesData()),
          )
        },
      },
    ],
    { target: gridRef },
  )

  return (
    <div className="demo-root">
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => refreshData()}
        >
          Regenerate Data
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => stressTest()}
        >
          Stress Test (1K rows)
        </button>
      </div>
      <div className="spacer-sm" />
      <p>
        Click and drag to select a range of cells. Hold Shift while clicking to
        extend the selection, or Ctrl/Cmd to add or subtract a rectangle. Arrow
        keys move the selection, Shift+Arrow extends it, Mod+A selects all,
        Mod+C copies, and Escape clears. Uncomment `enableCellSelection: false`
        on a column def to opt that column out of selection.
      </p>
      <p className="demo-note">
        Hiding, reordering, and pinning columns all keep a live selection
        anchored to the same cell ids. Ranges are indexed in render order, so a
        pinned column moves the rectangle with it rather than splitting it.
      </p>
      <div className="column-toggle-panel">
        <div className="column-toggle-panel-header">
          <label>
            <input
              type="checkbox"
              checked={table.getIsAllColumnsVisible()}
              onChange={table.getToggleAllColumnsVisibilityHandler()}
            />{' '}
            Toggle All
          </label>
        </div>
        {table.getAllLeafColumns().map((column) => (
          <div key={column.id} className="column-toggle-row">
            <label>
              <input
                type="checkbox"
                checked={column.getIsVisible()}
                onChange={column.getToggleVisibilityHandler()}
              />{' '}
              {column.id}
            </label>
          </div>
        ))}
      </div>
      <div className="spacer-sm" />
      <div className="button-row">
        <button
          className="demo-button demo-button-spaced"
          onClick={() => randomizeColumns()}
        >
          Shuffle Columns
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            table.setColumnOrder(
              [
                ...table.getAllLeafColumns().map((column) => column.id),
              ].reverse(),
            )
          }
        >
          Reverse Column Order
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnOrder()}
        >
          Reset Column Order
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnPinning()}
        >
          Reset Pinning
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnVisibility()}
        >
          Reset Visibility
        </button>
      </div>
      <div className="spacer-sm" />
      <table.Subscribe source={table.atoms.cellSelection}>
        {() => (
          <div>
            {table.getSelectedCellCount().toLocaleString()} cells selected
            across {table.getCellSelectionRowIds().length.toLocaleString()} rows
            and {table.getCellSelectionColumnIds().length} columns
          </div>
        )}
      </table.Subscribe>
      <div className="spacer-sm" />
      <div ref={gridRef} tabIndex={0}>
        <table>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  return (
                    <th key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder ? null : (
                        <>
                          <button
                            type="button"
                            disabled={!header.column.getCanSort()}
                            className={
                              header.column.getCanSort()
                                ? 'header-button sortable-header'
                                : 'header-button'
                            }
                            onClick={header.column.getToggleSortingHandler()}
                          >
                            <table.FlexRender header={header} />
                            {{ asc: ' 🔼', desc: ' 🔽' }[
                              header.column.getIsSorted() as string
                            ] ?? null}
                          </button>
                          {header.column.getCanPin() && (
                            <div className="pin-actions">
                              {header.column.getIsPinned() !== 'start' ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin('start')}
                                >
                                  {'<='}
                                </button>
                              ) : null}
                              {header.column.getIsPinned() ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin(false)}
                                >
                                  X
                                </button>
                              ) : null}
                              {header.column.getIsPinned() !== 'end' ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin('end')}
                                >
                                  {'=>'}
                                </button>
                              ) : null}
                            </div>
                          )}
                        </>
                      )}
                    </th>
                  )
                })}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              // one subscription per row rather than one around the whole
              // tbody, so a drag only re-renders the rows whose key changed
              <table.Subscribe
                key={row.id}
                source={table.atoms.cellSelection}
                selector={(ranges) =>
                  rowSelectionKey(
                    ranges,
                    table.getCellSelectionBounds(),
                    row.getDisplayIndex(),
                    row.id,
                  )
                }
              >
                {() => (
                  <tr>
                    {row.getVisibleCells().map((cell) =>
                      cell.getCanSelect() ? (
                        <td
                          key={cell.id}
                          className={getCellClassName(cell)}
                          tabIndex={cell.getTabIndex()}
                          onMouseDown={cell.getSelectionStartHandler()}
                          onMouseEnter={cell.getSelectionExtendHandler()}
                        >
                          <table.FlexRender cell={cell} />
                        </td>
                      ) : (
                        <td key={cell.id}>
                          <table.FlexRender cell={cell} />
                        </td>
                      ),
                    )}
                  </tr>
                )}
              </table.Subscribe>
            ))}
          </tbody>
          <tfoot>
            <tr>
              <td colSpan={20}>
                Rows ({table.getRowModel().rows.length.toLocaleString()})
              </td>
            </tr>
          </tfoot>
        </table>
      </div>
      <div className="spacer-sm" />
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            void navigator.clipboard.writeText(
              toTsv(table.getSelectedCellRangesData()),
            )
          }
        >
          Copy Selection
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.selectAllCells()}
        >
          Select All Cells
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetCellSelection(true)}
        >
          Clear Selection
        </button>
      </div>
      <hr />
      <br />
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            console.info(
              'table.getSelectedCellRangesData()',
              table.getSelectedCellRangesData(),
            )
          }
        >
          Log table.getSelectedCellRangesData()
        </button>
      </div>
      <div>
        <label>State:</label>
        <table.Subscribe selector={(state) => state}>
          {(state) => (
            <pre>{data.length < 1_001 && JSON.stringify(state, null, 2)}</pre>
          )}
        </table.Subscribe>
      </div>
      <div>
        <label htmlFor="paste-target">Paste Test:</label>
        {/* scratch area for pasting a copied selection back in, to eyeball
              the tab-separated shape. It sits outside the grid ref, so the
              table hotkeys never intercept typing or Mod+V in here. */}
        <textarea
          id="paste-target"
          className="text-input"
          rows={8}
          placeholder="Copy a selection, then paste here to check the tab-separated output..."
        />
      </div>
    </div>
  )
}

// each cell draws only the sides getSelectionEdges() reports as the boundary of
// the selection, so a union of rectangles gets one continuous outline
/**
 * The narrowest value a row's appearance depends on: whether its own cells fall
 * inside a range, whether the rows immediately above and below do (that decides
 * its top and bottom edges), and whether it owns the focused cell.
 *
 * Rows in the middle of a growing range produce an unchanged key, so <Subscribe>
 * skips re-rendering them. Column layout is deliberately absent: those changes
 * re-render App, which recreates these rows anyway.
 */
function rowSelectionKey(
  ranges: CellSelectionState,
  bounds: Array<CellSelectionBounds>,
  rowIndex: number,
  rowId: string,
) {
  const active = ranges[ranges.length - 1]
  let key =
    ranges.length > 0 && active.anchorRowId === rowId
      ? `f${active.anchorColumnId}`
      : ''

  for (const bound of bounds) {
    const self = rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex
    const above =
      rowIndex - 1 >= bound.minRowIndex && rowIndex - 1 <= bound.maxRowIndex
    const below =
      rowIndex + 1 >= bound.minRowIndex && rowIndex + 1 <= bound.maxRowIndex

    if (self || above || below) {
      key += `|${self ? 1 : 0}${above ? 1 : 0}${below ? 1 : 0}:${bound.minColumnIndex}-${bound.maxColumnIndex}`
    }
  }

  return key
}

function getCellClassName(cell: Cell<typeof features, Person>) {
  // Most cells in a large grid are unselected, so bail before asking for edges.
  // getSelectionEdges() would otherwise resolve the cell's position a second
  // time just to discover it is not selected.
  if (!cell.getIsSelected()) {
    return cell.getIsFocused()
      ? 'cell-selectable cell-focused'
      : 'cell-selectable'
  }

  const edges = cell.getSelectionEdges()

  return [
    'cell-selectable',
    '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(' ')
}

const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')

ReactDOM.createRoot(rootElement).render(
  <React.StrictMode>
    <App />
    <TanStackDevtools plugins={[tableDevtoolsPlugin()]} />
  </React.StrictMode>,
)
import React from 'react'
import { faker } from '@faker-js/faker'
import { TanStackDevtools } from '@tanstack/react-devtools'
import ReactDOM from 'react-dom/client'
import {
  cellSelectionFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createSortedRowModel,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
  useTable,
} from '@tanstack/react-table'
import {
  tableDevtoolsPlugin,
  useTanStackTableDevtools,
} from '@tanstack/react-table-devtools'
import { useHotkeys } from '@tanstack/react-hotkeys'
import { useCreateAtom } from '@tanstack/react-store'
import { makeData } from './makeData'
import type { Person } from './makeData'
import type {
  Cell,
  CellSelectionBounds,
  CellSelectionState,
} from '@tanstack/react-table'
import './index.css'

const features = tableFeatures({
  cellSelectionFeature,
  columnOrderingFeature,
  columnPinningFeature,
  columnVisibilityFeature,
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: {
    alphanumeric: sortFn_alphanumeric,
    datetime: sortFn_datetime,
    text: sortFn_text,
  },
})

const columnHelper = createColumnHelper<typeof features, Person>()

// Serializing a selection for the clipboard is a userland concern: the table
// hands back `getSelectedCellRangesData()` as raw values, and the delimiter,
// the null representation, and the quoting rules are all yours to pick. This is
// the spreadsheet-flavored version.
function escapeTsvValue(value: unknown) {
  const text = value == null ? '' : String(value)
  const safeText =
    typeof value === 'string' && /^[\t\r ]*[=+@-]/.test(value)
      ? `'${text}`
      : text

  // spreadsheets expect a field to be quoted 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') // blank line between final selected regions
}

function App() {
  const columns = React.useMemo(
    () =>
      columnHelper.columns([
        columnHelper.accessor('firstName', {
          header: 'First Name',
          cell: (info) => info.getValue(),
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor((row) => row.lastName, {
          id: 'lastName',
          header: () => <span>Last Name</span>,
          cell: (info) => info.getValue(),
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('age', {
          header: () => 'Age',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('visits', {
          header: () => <span>Visits</span>,
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('status', {
          header: 'Status',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('progress', {
          header: 'Profile Progress',
          // enableCellSelection: false, // this column opts out of cell selection
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('email', {
          header: 'Email',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('phone', {
          header: 'Phone',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('city', {
          header: 'City',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('country', {
          header: 'Country',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('department', {
          header: 'Department',
          footer: (props) => props.column.id,
        }),
        columnHelper.accessor('salary', {
          header: 'Salary',
          cell: (info) => info.getValue().toLocaleString(),
          footer: (props) => props.column.id,
        }),
      ]),
    [],
  )

  const [data, setData] = React.useState(() => makeData(20))
  const refreshData = () => setData(makeData(20))
  const stressTest = () => setData(makeData(1_000))

  // optionally, raise the selection state to your own atom
  const cellSelectionAtom = useCreateAtom<CellSelectionState>([])

  const table = useTable(
    {
      key: 'cell-selection', // needed for devtools
      features,
      atoms: {
        cellSelection: cellSelectionAtom,
      },
      columns,
      data,
      getRowId: (row) => row.id,
      enableCellSelection: true, // enable cell selection for all cells
      // initialState: { cellSelection: [] }, // select cells on first render
      // state: { cellSelection }, // classic controlled state; pair with onCellSelectionChange
      // onCellSelectionChange: setCellSelection,
      // enableCellRangeSelection: false, // disable Shift-click and drag ranges; default true
      // enableMultiCellRangeSelection: false, // allow only one rectangle at a time; default true
      // enableCellSelectionDrag: false, // disable drag-to-select; default true
      // isCellRangeSelectionEvent: event => Boolean(event.metaKey), // use Meta instead of Shift
      debugTable: true,
    },
    // Every slice that changes the table's structure has to be here, or the
    // headers and controls will not re-render when you pin, hide, or reorder.
    // cellSelection is the deliberate exception: it is read through <Subscribe>
    // instead, so a drag does not re-render the whole example.
    (state) => ({
      sorting: state.sorting,
      columnVisibility: state.columnVisibility,
      columnOrder: state.columnOrder,
      columnPinning: state.columnPinning,
    }),
  )

  useTanStackTableDevtools(table)

  const randomizeColumns = () => {
    table.setColumnOrder(
      faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
    )
  }

  // optionally, reset the cellSelection state not only when data changes, but also when column order, pinning, visibility, or sorting changes
  // customize this to your needs
  const isFirstColumnLayout = React.useRef(true)
  React.useEffect(() => {
    if (isFirstColumnLayout.current) {
      isFirstColumnLayout.current = false
      return
    }
    table.resetCellSelection(true)
  }, [
    table.state.columnOrder,
    table.state.columnPinning,
    table.state.columnVisibility,
    table.state.sorting,
  ])

  const gridRef = React.useRef<HTMLDivElement>(null)

  // keyboard navigation is TanStack Hotkeys driving the table's imperative APIs;
  // table-core ships no keydown handling of its own
  useHotkeys(
    [
      { hotkey: 'ArrowUp', callback: () => table.moveCellSelection('up') },
      { hotkey: 'ArrowDown', callback: () => table.moveCellSelection('down') },
      { hotkey: 'ArrowLeft', callback: () => table.moveCellSelection('left') },
      {
        hotkey: 'ArrowRight',
        callback: () => table.moveCellSelection('right'),
      },
      {
        hotkey: 'Shift+ArrowUp',
        callback: () => table.extendCellSelection('up'),
      },
      {
        hotkey: 'Shift+ArrowDown',
        callback: () => table.extendCellSelection('down'),
      },
      {
        hotkey: 'Shift+ArrowLeft',
        callback: () => table.extendCellSelection('left'),
      },
      {
        hotkey: 'Shift+ArrowRight',
        callback: () => table.extendCellSelection('right'),
      },
      { hotkey: 'Mod+A', callback: () => table.selectAllCells() },
      { hotkey: 'Escape', callback: () => table.resetCellSelection(true) },
      {
        hotkey: 'Mod+C',
        callback: () => {
          void navigator.clipboard.writeText(
            toTsv(table.getSelectedCellRangesData()),
          )
        },
      },
    ],
    { target: gridRef },
  )

  return (
    <div className="demo-root">
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => refreshData()}
        >
          Regenerate Data
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => stressTest()}
        >
          Stress Test (1K rows)
        </button>
      </div>
      <div className="spacer-sm" />
      <p>
        Click and drag to select a range of cells. Hold Shift while clicking to
        extend the selection, or Ctrl/Cmd to add or subtract a rectangle. Arrow
        keys move the selection, Shift+Arrow extends it, Mod+A selects all,
        Mod+C copies, and Escape clears. Uncomment `enableCellSelection: false`
        on a column def to opt that column out of selection.
      </p>
      <p className="demo-note">
        Hiding, reordering, and pinning columns all keep a live selection
        anchored to the same cell ids. Ranges are indexed in render order, so a
        pinned column moves the rectangle with it rather than splitting it.
      </p>
      <div className="column-toggle-panel">
        <div className="column-toggle-panel-header">
          <label>
            <input
              type="checkbox"
              checked={table.getIsAllColumnsVisible()}
              onChange={table.getToggleAllColumnsVisibilityHandler()}
            />{' '}
            Toggle All
          </label>
        </div>
        {table.getAllLeafColumns().map((column) => (
          <div key={column.id} className="column-toggle-row">
            <label>
              <input
                type="checkbox"
                checked={column.getIsVisible()}
                onChange={column.getToggleVisibilityHandler()}
              />{' '}
              {column.id}
            </label>
          </div>
        ))}
      </div>
      <div className="spacer-sm" />
      <div className="button-row">
        <button
          className="demo-button demo-button-spaced"
          onClick={() => randomizeColumns()}
        >
          Shuffle Columns
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            table.setColumnOrder(
              [
                ...table.getAllLeafColumns().map((column) => column.id),
              ].reverse(),
            )
          }
        >
          Reverse Column Order
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnOrder()}
        >
          Reset Column Order
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnPinning()}
        >
          Reset Pinning
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetColumnVisibility()}
        >
          Reset Visibility
        </button>
      </div>
      <div className="spacer-sm" />
      <table.Subscribe source={table.atoms.cellSelection}>
        {() => (
          <div>
            {table.getSelectedCellCount().toLocaleString()} cells selected
            across {table.getCellSelectionRowIds().length.toLocaleString()} rows
            and {table.getCellSelectionColumnIds().length} columns
          </div>
        )}
      </table.Subscribe>
      <div className="spacer-sm" />
      <div ref={gridRef} tabIndex={0}>
        <table>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  return (
                    <th key={header.id} colSpan={header.colSpan}>
                      {header.isPlaceholder ? null : (
                        <>
                          <button
                            type="button"
                            disabled={!header.column.getCanSort()}
                            className={
                              header.column.getCanSort()
                                ? 'header-button sortable-header'
                                : 'header-button'
                            }
                            onClick={header.column.getToggleSortingHandler()}
                          >
                            <table.FlexRender header={header} />
                            {{ asc: ' 🔼', desc: ' 🔽' }[
                              header.column.getIsSorted() as string
                            ] ?? null}
                          </button>
                          {header.column.getCanPin() && (
                            <div className="pin-actions">
                              {header.column.getIsPinned() !== 'start' ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin('start')}
                                >
                                  {'<='}
                                </button>
                              ) : null}
                              {header.column.getIsPinned() ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin(false)}
                                >
                                  X
                                </button>
                              ) : null}
                              {header.column.getIsPinned() !== 'end' ? (
                                <button
                                  className="pin-button"
                                  onClick={() => header.column.pin('end')}
                                >
                                  {'=>'}
                                </button>
                              ) : null}
                            </div>
                          )}
                        </>
                      )}
                    </th>
                  )
                })}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              // one subscription per row rather than one around the whole
              // tbody, so a drag only re-renders the rows whose key changed
              <table.Subscribe
                key={row.id}
                source={table.atoms.cellSelection}
                selector={(ranges) =>
                  rowSelectionKey(
                    ranges,
                    table.getCellSelectionBounds(),
                    row.getDisplayIndex(),
                    row.id,
                  )
                }
              >
                {() => (
                  <tr>
                    {row.getVisibleCells().map((cell) =>
                      cell.getCanSelect() ? (
                        <td
                          key={cell.id}
                          className={getCellClassName(cell)}
                          tabIndex={cell.getTabIndex()}
                          onMouseDown={cell.getSelectionStartHandler()}
                          onMouseEnter={cell.getSelectionExtendHandler()}
                        >
                          <table.FlexRender cell={cell} />
                        </td>
                      ) : (
                        <td key={cell.id}>
                          <table.FlexRender cell={cell} />
                        </td>
                      ),
                    )}
                  </tr>
                )}
              </table.Subscribe>
            ))}
          </tbody>
          <tfoot>
            <tr>
              <td colSpan={20}>
                Rows ({table.getRowModel().rows.length.toLocaleString()})
              </td>
            </tr>
          </tfoot>
        </table>
      </div>
      <div className="spacer-sm" />
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            void navigator.clipboard.writeText(
              toTsv(table.getSelectedCellRangesData()),
            )
          }
        >
          Copy Selection
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.selectAllCells()}
        >
          Select All Cells
        </button>
        <button
          className="demo-button demo-button-spaced"
          onClick={() => table.resetCellSelection(true)}
        >
          Clear Selection
        </button>
      </div>
      <hr />
      <br />
      <div>
        <button
          className="demo-button demo-button-spaced"
          onClick={() =>
            console.info(
              'table.getSelectedCellRangesData()',
              table.getSelectedCellRangesData(),
            )
          }
        >
          Log table.getSelectedCellRangesData()
        </button>
      </div>
      <div>
        <label>State:</label>
        <table.Subscribe selector={(state) => state}>
          {(state) => (
            <pre>{data.length < 1_001 && JSON.stringify(state, null, 2)}</pre>
          )}
        </table.Subscribe>
      </div>
      <div>
        <label htmlFor="paste-target">Paste Test:</label>
        {/* scratch area for pasting a copied selection back in, to eyeball
              the tab-separated shape. It sits outside the grid ref, so the
              table hotkeys never intercept typing or Mod+V in here. */}
        <textarea
          id="paste-target"
          className="text-input"
          rows={8}
          placeholder="Copy a selection, then paste here to check the tab-separated output..."
        />
      </div>
    </div>
  )
}

// each cell draws only the sides getSelectionEdges() reports as the boundary of
// the selection, so a union of rectangles gets one continuous outline
/**
 * The narrowest value a row's appearance depends on: whether its own cells fall
 * inside a range, whether the rows immediately above and below do (that decides
 * its top and bottom edges), and whether it owns the focused cell.
 *
 * Rows in the middle of a growing range produce an unchanged key, so <Subscribe>
 * skips re-rendering them. Column layout is deliberately absent: those changes
 * re-render App, which recreates these rows anyway.
 */
function rowSelectionKey(
  ranges: CellSelectionState,
  bounds: Array<CellSelectionBounds>,
  rowIndex: number,
  rowId: string,
) {
  const active = ranges[ranges.length - 1]
  let key =
    ranges.length > 0 && active.anchorRowId === rowId
      ? `f${active.anchorColumnId}`
      : ''

  for (const bound of bounds) {
    const self = rowIndex >= bound.minRowIndex && rowIndex <= bound.maxRowIndex
    const above =
      rowIndex - 1 >= bound.minRowIndex && rowIndex - 1 <= bound.maxRowIndex
    const below =
      rowIndex + 1 >= bound.minRowIndex && rowIndex + 1 <= bound.maxRowIndex

    if (self || above || below) {
      key += `|${self ? 1 : 0}${above ? 1 : 0}${below ? 1 : 0}:${bound.minColumnIndex}-${bound.maxColumnIndex}`
    }
  }

  return key
}

function getCellClassName(cell: Cell<typeof features, Person>) {
  // Most cells in a large grid are unselected, so bail before asking for edges.
  // getSelectionEdges() would otherwise resolve the cell's position a second
  // time just to discover it is not selected.
  if (!cell.getIsSelected()) {
    return cell.getIsFocused()
      ? 'cell-selectable cell-focused'
      : 'cell-selectable'
  }

  const edges = cell.getSelectionEdges()

  return [
    'cell-selectable',
    '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(' ')
}

const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')

ReactDOM.createRoot(rootElement).render(
  <React.StrictMode>
    <App />
    <TanStackDevtools plugins={[tableDevtoolsPlugin()]} />
  </React.StrictMode>,
)