Foundations

Cell selection

Selecting the cells of a grid in rectangles, with one active cell among them.

Markdown

In a grid, people select cells in rectangles: one cell, a block, a whole row or column, or several of these at once. One cell among them is the active cell, where typing goes.

Rectangles

Click a cell, then Shift-click another, or drag. Command-click adds a cell; a column's header selects the column; Command-A selects everything and leaves the active cell where it was.

Select all
Name
Price
In stock
Status
Released
Featured
1
$8.00
0
Draft
2
Rust Kettle 1
$35.61
161
Draft
Feb 6, 2026
3
Ink Mug 1
$10.26
226
In review
Aug 7, 2026
4
Oatmeal Saucer 1
$37.87
387
In review
Sep 12, 2026
5
Rust Tray 1
$12.52
52
Live
Mar 13, 2026
6
Ink Jug 1
$77.17
117
Draft
Aug 14, 2026
7
Oatmeal Bowl 1
$14.78
278
Draft
Oct 19, 2026
8
Rust Plate 1
$79.43
343
In review
Mar 20, 2026

12 rows ยท 0 edits

Code
"use client";

import { ProductGrid } from "./products.tsx";

export default function Sheet() {
  return <ProductGrid count={12} height={300} />;
}

Best practices

Keep one active cell. Whatever is selected, exactly one cell is active: it takes what is typed, and it is where a range is measured from. Show it apart from the rest of the selection.

Extend from the active cell. Shift with an arrow key, or Shift-click, draws a rectangle from the active cell to the cell indicated. The active cell does not move; the far corner does. Extending again redraws the rectangle rather than adding to it.

Let a drag draw the rectangle. Pressing a cell and dragging to another selects the block between them.

Give a finger two handles. A finger has no Shift, and its drag scrolls. Under touch the selection carries a handle at its start corner and one at its end, each large enough to press. Dragging a handle takes its corner with it; the opposite corner stays where it is and is the active cell, since that is what a range is measured from. A corner dragged past the one that stays turns the rectangle over.

Add with the command key. Command-click adds a cell to the selection and makes it the active cell; extending then extends only that addition.

Select whole rows and columns. A press on a column's header selects the column, however long it is; with Shift, the columns from the active cell's to it. From the keyboard, Shift with Space selects the active cell's row and Control with Space its column; a press on a row's header selects the row too. The active cell stays where it was, and Shift with an arrow along whole rows or columns takes in the next whole one.

Select all without losing place. Selecting everything leaves the active cell where it was.

Act on the whole selection. Clearing applies to every selected cell. Copying, cutting and pasting apply to the selection when it is one rectangle, and to the active range when it is several apart โ€” so that what a cut clears is exactly what it copied.

Keep the selection under a secondary press. A secondary press on a selected cell leaves the selection as it is; on any other cell it selects that cell.

Dim the selection when focus is elsewhere. A selection in a grid that does not have focus stays visible, in a subdued form.

Hold the selection through scrolling. Cells that scroll out of view remain selected, and the selection is still there when they return.

Conventions

IntentPointerKeyboardTouch
Make a cell activeClickArrow keysTap
Select a blockDrag, or Shift-clickShift with arrow keysDrag a handle
Add a cellCommand-clickโ€”โ€”
Select a columnPress its headerControl-SpaceTap its header
Select a rowPress its headerShift-SpaceTap its header
Select everythingโ€”Command-Aโ€”
Return to one cellClick a cellEscapeTap a cell

Command is โŒ˜ on Apple platforms and Ctrl elsewhere.

See also Selection and Grid navigation.

Behaviour

Selecting a cell selects it alone, and makes it the active cell
const selection = selectCell(cell(2, 3));
expect(isCellSelected(selection, 2, 3)).toBe(true);
expect(isCellSelected(selection, 2, 4)).toBe(false);
expect(activeCell(selection)).toEqual(cell(2, 3));
Extending selects the rectangle between the active cell and the cell, in any direction
fc.assert(
  fc.property(anyCell, anyCell, anyCell, (from, to, probe) => {
    const selection = extendTo(selectCell(from), to);
    const inside =
      probe.row >= Math.min(from.row, to.row) &&
      probe.row <= Math.max(from.row, to.row) &&
      probe.column >= Math.min(from.column, to.column) &&
      probe.column <= Math.max(from.column, to.column);
    return isCellSelected(selection, probe.row, probe.column) === inside;
  }),
);
Extending leaves the active cell where it was, and moves the far corner
const selection = extendTo(selectCell(cell(5, 5)), cell(2, 8));
expect(activeCell(selection)).toEqual(cell(5, 5));
expect(focusCell(selection)).toEqual(cell(2, 8));
expect(boundsOf(selection[0]!)).toEqual({ top: 2, left: 5, bottom: 5, right: 8 });
Extending again redraws the range from the same active cell
const selection = extendTo(extendTo(selectCell(cell(5, 5)), cell(9, 9)), cell(4, 4));
expect(isCellSelected(selection, 9, 9)).toBe(false);
expect(isCellSelected(selection, 4, 4)).toBe(true);
Extending to where the range already ends returns the same selection
const selection = extendTo(selectCell(cell(1, 1)), cell(3, 3));
expect(extendTo(selection, cell(3, 3))).toBe(selection);
Adding a cell keeps what was selected, and the added cell becomes active
const selection = addCell(extendTo(selectCell(cell(0, 0)), cell(1, 1)), cell(5, 5));
expect(isCellSelected(selection, 1, 0)).toBe(true);
expect(isCellSelected(selection, 5, 5)).toBe(true);
expect(isCellSelected(selection, 3, 3)).toBe(false);
expect(activeCell(selection)).toEqual(cell(5, 5));
Extending after adding extends only the added range
const selection = extendTo(addCell(selectCell(cell(0, 0)), cell(5, 5)), cell(6, 6));
expect(isCellSelected(selection, 0, 0)).toBe(true);
expect(isCellSelected(selection, 6, 5)).toBe(true);
expect(isCellSelected(selection, 3, 3)).toBe(false);
Whole rows and whole columns are single ranges, however long
const rows = selectRows(2, 4, 12);
expect(rows).toHaveLength(1);
expect(isCellSelected(rows, 3, 11)).toBe(true);
expect(isCellSelected(rows, 5, 0)).toBe(false);
const columns = selectColumns(1, 1, 1_000_000);
expect(columns).toHaveLength(1);
expect(isCellSelected(columns, 999_999, 1)).toBe(true);
expect(isCellSelected(columns, 0, 2)).toBe(false);
Selecting all selects every cell and leaves the active cell where it was
const selection = selectAll(selectCell(cell(3, 2)), 100, 10);
expect(isCellSelected(selection, 0, 0)).toBe(true);
expect(isCellSelected(selection, 99, 9)).toBe(true);
expect(activeCell(selection)).toEqual(cell(3, 2));
When the grid shrinks, the selection is held inside it
const selection = clampSelection(extendTo(selectCell(cell(2, 2)), cell(50, 50)), 10, 5);
expect(focusCell(selection)).toEqual(cell(9, 4));
expect(clampSelection(selection, 10, 5)).toBe(selection);
expect(clampSelection(selection, 0, 5)).toEqual([]);
What is copied is the whole selection when it is one rectangle, and the active range when it is not
// Every cell, as select-all makes it: the grid, with the active cell's range within.
expect(clipboardBounds(selectAll(selectCell(cell(1, 1)), 4, 3))).toEqual({
  top: 0,
  left: 0,
  bottom: 3,
  right: 2,
});
expect(clipboardBounds(extendTo(selectCell(cell(2, 2)), cell(0, 1)))).toEqual({
  top: 0,
  left: 1,
  bottom: 2,
  right: 2,
});
// Two ranges apart: only the active one.
expect(clipboardBounds(addCell(selectCell(cell(0, 0)), cell(3, 2)))).toEqual({
  top: 3,
  left: 2,
  bottom: 3,
  right: 2,
});
expect(clipboardBounds([])).toBeNull();
A rectangle held by a corner is redrawn from the opposite corner, which stays and is the active cell
const bounds = { top: 2, left: 1, bottom: 4, right: 3 };
const byEnd = dragCorner(bounds, "end", cell(6, 5));
expect(activeCell(byEnd)).toEqual(cell(2, 1));
expect(boundsOf(byEnd[0]!)).toEqual({ top: 2, left: 1, bottom: 6, right: 5 });
const byStart = dragCorner(bounds, "start", cell(0, 0));
expect(activeCell(byStart)).toEqual(cell(4, 3));
expect(boundsOf(byStart[0]!)).toEqual({ top: 0, left: 0, bottom: 4, right: 3 });
A corner taken past the one that stays turns the rectangle over
const turned = dragCorner({ top: 2, left: 1, bottom: 4, right: 3 }, "start", cell(7, 6));
expect(boundsOf(turned[0]!)).toEqual({ top: 4, left: 3, bottom: 7, right: 6 });
expect(activeCell(turned)).toEqual(cell(4, 3));
A row's spans say of every column what the selection says of that cell
fc.assert(
  fc.property(
    fc.array(fc.tuple(anyCell, anyCell), { maxLength: 4 }),
    fc.nat(30),
    fc.nat(30),
    (ranges, row, column) => {
      const selection = ranges.map(([anchor, focus]) => ({ anchor, focus }));
      return inSpans(rowSpans(selection, row), column) === isCellSelected(selection, row, column);
    },
  ),
);
Rows with the same stake in a selection have the same spans, so they need not be drawn again
const selection = extendTo(selectCell(cell(2, 1)), cell(5, 3));
expect(rowSpans(selection, 3)).toBe(rowSpans(selection, 4));
expect(rowSpans(selection, 6)).toBe("");

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/grid-selection.json
Source

src/primitives/grid-selection.ts

/**
 * Selection over the cells of a grid, as rectangles. A range is two corners โ€”
 * the anchor, which stays, and the focus, which moves as the range is extended
 * โ€” so a column of a million rows is one range, and extending it is changing
 * one number.
 *
 * A selection is a list of ranges. The last is the active one: its anchor is
 * the active cell, where typing goes, and Shift extends it. Command-click adds
 * a range and makes it active.
 */

export interface Cell {
  readonly row: number;
  readonly column: number;
}

export interface CellRange {
  readonly anchor: Cell;
  readonly focus: Cell;
}

export type GridSelection = readonly CellRange[];

export const EMPTY_GRID_SELECTION: GridSelection = Object.freeze([]);

export interface Bounds {
  readonly top: number;
  readonly left: number;
  /** Inclusive, as are the others. */
  readonly bottom: number;
  readonly right: number;
}

export function boundsOf({ anchor, focus }: CellRange): Bounds {
  return {
    top: Math.min(anchor.row, focus.row),
    left: Math.min(anchor.column, focus.column),
    bottom: Math.max(anchor.row, focus.row),
    right: Math.max(anchor.column, focus.column),
  };
}

/** One cell selected, and active. */
export function selectCell(cell: Cell): GridSelection {
  return [{ anchor: cell, focus: cell }];
}

/** The active range redrawn from its anchor to `cell`; other ranges are kept. */
export function extendTo(selection: GridSelection, cell: Cell): GridSelection {
  const active = selection.at(-1);
  if (active === undefined) return selectCell(cell);
  if (active.focus.row === cell.row && active.focus.column === cell.column) return selection;
  return [...selection.slice(0, -1), { anchor: active.anchor, focus: cell }];
}

/** The corner a rectangle is held by: where it starts, or where it ends, in both directions. */
export type Corner = "start" | "end";

/**
 * One rectangle, redrawn by a corner taken to `cell`: the opposite corner stays
 * where it is and is the active cell, which is what a range is measured from.
 * A corner taken past the one that stays turns the rectangle over.
 */
export function dragCorner(bounds: Bounds, corner: Corner, cell: Cell): GridSelection {
  const anchor =
    corner === "start"
      ? { row: bounds.bottom, column: bounds.right }
      : { row: bounds.top, column: bounds.left };
  return [{ anchor, focus: cell }];
}

/** A further range of one cell, which becomes the active one. */
export function addCell(selection: GridSelection, cell: Cell): GridSelection {
  return [...selection, { anchor: cell, focus: cell }];
}

/** Every cell of rows `from` through `to`, with the active cell in the first column of `from`. */
export function selectRows(from: number, to: number, columnCount: number): GridSelection {
  return [{ anchor: { row: from, column: 0 }, focus: { row: to, column: columnCount - 1 } }];
}

/** Every cell of columns `from` through `to`, with the active cell in the first row of `from`. */
export function selectColumns(from: number, to: number, rowCount: number): GridSelection {
  return [{ anchor: { row: 0, column: from }, focus: { row: rowCount - 1, column: to } }];
}

/** Every cell, keeping the active cell where it is. */
export function selectAll(selection: GridSelection, rowCount: number, columnCount: number): GridSelection {
  if (rowCount === 0 || columnCount === 0) return EMPTY_GRID_SELECTION;
  const active = activeCell(selection) ?? { row: 0, column: 0 };
  // Two ranges cover everything while leaving the anchor on the active cell:
  // the whole grid, then the active cell's own range drawn over it.
  return [
    { anchor: { row: 0, column: 0 }, focus: { row: rowCount - 1, column: columnCount - 1 } },
    { anchor: active, focus: active },
  ];
}

/** The cell that typing goes to: the anchor of the active range. */
export function activeCell(selection: GridSelection): Cell | null {
  return selection.at(-1)?.anchor ?? null;
}

/** The far corner of the active range: what Shift with an arrow moves. */
export function focusCell(selection: GridSelection): Cell | null {
  return selection.at(-1)?.focus ?? null;
}

/**
 * The one rectangle a selection is, when it is one: a range that holds every
 * other โ€” all cells, with the active cell's own range within it. Otherwise the
 * active range. This is what is copied, cut and pasted over, so that what is
 * cleared by a cut is exactly what was copied.
 */
export function clipboardBounds(selection: GridSelection): Bounds | null {
  const active = selection.at(-1);
  if (active === undefined) return null;
  const bounds = selection.map(boundsOf);
  const whole = bounds.find((outer) =>
    bounds.every(
      (inner) =>
        inner.top >= outer.top &&
        inner.left >= outer.left &&
        inner.bottom <= outer.bottom &&
        inner.right <= outer.right,
    ),
  );
  return whole ?? boundsOf(active);
}

/** The columns of one row that are selected, as spans `first-last` joined by commas: a row's whole stake in a selection. */
export function rowSpans(selection: GridSelection, row: number): string {
  let spans = "";
  for (const range of selection) {
    const { top, left, bottom, right } = boundsOf(range);
    if (row >= top && row <= bottom) spans += `${spans === "" ? "" : ","}${left}-${right}`;
  }
  return spans;
}

/** Whether a column is within spans written by `rowSpans`. */
export function inSpans(spans: string, column: number): boolean {
  if (spans === "") return false;
  for (const span of spans.split(",")) {
    const dash = span.indexOf("-");
    if (column >= Number(span.slice(0, dash)) && column <= Number(span.slice(dash + 1))) return true;
  }
  return false;
}

export function isCellSelected(selection: GridSelection, row: number, column: number): boolean {
  for (const range of selection) {
    const { top, left, bottom, right } = boundsOf(range);
    if (row >= top && row <= bottom && column >= left && column <= right) return true;
  }
  return false;
}

/** The selection with every cell held inside a grid that has shrunk. */
export function clampSelection(
  selection: GridSelection,
  rowCount: number,
  columnCount: number,
): GridSelection {
  if (rowCount === 0 || columnCount === 0) return EMPTY_GRID_SELECTION;
  const clamp = (cell: Cell): Cell => ({
    row: Math.min(Math.max(cell.row, 0), rowCount - 1),
    column: Math.min(Math.max(cell.column, 0), columnCount - 1),
  });
  let changed = false;
  const next = selection.map((range) => {
    const anchor = clamp(range.anchor);
    const focus = clamp(range.focus);
    if (
      anchor.row === range.anchor.row &&
      anchor.column === range.anchor.column &&
      focus.row === range.focus.row &&
      focus.column === range.focus.column
    ) {
      return range;
    }
    changed = true;
    return { anchor, focus };
  });
  return changed ? next : selection;
}