Foundations

Grid navigation

Moving the active cell through a grid by the keyboard, to its neighbours and to its edges.

Markdown

In a grid, the keyboard moves the active cell in two directions, and reaches the far edges of data too large to see at once.

Moving through a grid

Click a cell, then use the arrow keys, Home and End, alone and with the command key.

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

Move one cell with each arrow key, in the direction of the arrow. Where text reads right to left, Left and Right are mirrored with the columns.

Reach the edges directly. Home and End go to the first and last cell of the row. With the command key, an arrow goes to the edge in its direction, and Home and End to the first and last cells of the whole grid.

Move by the page. Page Up and Page Down move by the rows in view, so two presses never skip rows and never repeat them.

Keep the active cell in view. When a move lands on a cell outside the view, scroll by the least that shows it.

Stop at the edges. A move past the first or last row or column stays where it is; a grid does not wrap.

Make the grid one stop in the Tab sequence. Tab enters the grid at its active cell and leaves it again; the arrow keys move within. While a cell is being edited, Tab commits it and moves to the next cell, as people filling in a row expect.

Keys

KeyMoves the active cell
Arrow keysOne cell
Command with an arrow keyTo the edge of the grid in that direction
Home, EndTo the first or last cell of the row
Command-Home, Command-EndTo the first or last cell of the grid
Page Up, Page DownBy the rows in view

See also Keyboard navigation and Cell selection.

Behaviour

Arrow keys move one cell
expect(land("ArrowDown")).toEqual({ row: 51, column: 3 });
expect(land("ArrowUp")).toEqual({ row: 49, column: 3 });
expect(land("ArrowRight")).toEqual({ row: 50, column: 4 });
expect(land("ArrowLeft")).toEqual({ row: 50, column: 2 });
Left and right are mirrored where text reads right to left
expect(land("ArrowRight", { direction: "rtl" })).toEqual({ row: 50, column: 2 });
expect(land("ArrowLeft", { direction: "rtl" })).toEqual({ row: 50, column: 4 });
With the command key, an arrow goes to the edge of the grid
expect(land("ArrowDown", { command: true })).toEqual({ row: 99, column: 3 });
expect(land("ArrowUp", { command: true })).toEqual({ row: 0, column: 3 });
expect(land("ArrowRight", { command: true })).toEqual({ row: 50, column: 7 });
expect(land("ArrowRight", { command: true, direction: "rtl" })).toEqual({ row: 50, column: 0 });
Home and End go to the ends of the row
expect(land("Home")).toEqual({ row: 50, column: 0 });
expect(land("End")).toEqual({ row: 50, column: 7 });
With the command key, Home and End go to the first and last cells of the grid
expect(land("Home", { command: true })).toEqual({ row: 0, column: 0 });
expect(land("End", { command: true })).toEqual({ row: 99, column: 7 });
Page Up and Page Down move by the rows in view
expect(land("PageDown")).toEqual({ row: 70, column: 3 });
expect(land("PageUp")).toEqual({ row: 30, column: 3 });
No move leaves the grid
expect(land("ArrowUp", { at: { row: 0, column: 0 } })).toEqual({ row: 0, column: 0 });
expect(land("ArrowLeft", { at: { row: 0, column: 0 } })).toEqual({ row: 0, column: 0 });
expect(land("PageDown", { at: { row: 95, column: 7 } })).toEqual({ row: 99, column: 7 });
expect(land("ArrowRight", { at: { row: 99, column: 7 } })).toEqual({ row: 99, column: 7 });
Other keys are not moves
expect(land("Enter")).toBeNull();
expect(land("a")).toBeNull();

React

Install

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

src/primitives/grid-navigation.ts

/**
 * Moving through the cells of a grid by the keyboard, as spreadsheets do.
 *
 * Arrow keys move one cell; with the command key they go to the edge. Home and
 * End go to the ends of the row; with the command key, to the first and last
 * cells of the grid. Page Up and Page Down move by a page of rows. Left and
 * right are mirrored where text reads right to left.
 */
import type { Direction } from "./navigation.ts";
import type { Cell } from "./grid-selection.ts";

export type GridMove =
  | { readonly kind: "step"; readonly rows: number; readonly columns: number }
  | { readonly kind: "edge"; readonly rows: number; readonly columns: number }
  | { readonly kind: "page"; readonly pages: 1 | -1 }
  | { readonly kind: "first" | "last" };

export interface GridKey {
  readonly key: string;
  /** The platform's command key: โŒ˜ on Apple platforms, Ctrl elsewhere. */
  readonly command: boolean;
}

export function gridMoveForKey({ key, command }: GridKey, direction: Direction): GridMove | null {
  const forward = direction === "rtl" ? -1 : 1;
  const kind = command ? "edge" : "step";
  switch (key) {
    case "ArrowDown":
      return { kind, rows: 1, columns: 0 };
    case "ArrowUp":
      return { kind, rows: -1, columns: 0 };
    case "ArrowRight":
      return { kind, rows: 0, columns: forward };
    case "ArrowLeft":
      return { kind, rows: 0, columns: -forward };
    case "Home":
      return command ? { kind: "first" } : { kind: "edge", rows: 0, columns: -1 };
    case "End":
      return command ? { kind: "last" } : { kind: "edge", rows: 0, columns: 1 };
    case "PageDown":
      return { kind: "page", pages: 1 };
    case "PageUp":
      return { kind: "page", pages: -1 };
    default:
      return null;
  }
}

export interface GridExtent {
  readonly rowCount: number;
  readonly columnCount: number;
  /** How many rows a page is: the rows in view. */
  readonly pageSize: number;
}

/** Where a move from `cell` lands, held inside the grid. */
export function applyGridMove(
  cell: Cell,
  move: GridMove,
  { rowCount, columnCount, pageSize }: GridExtent,
): Cell {
  const lastRow = Math.max(rowCount - 1, 0);
  const lastColumn = Math.max(columnCount - 1, 0);
  const hold = (row: number, column: number): Cell => ({
    row: Math.min(Math.max(row, 0), lastRow),
    column: Math.min(Math.max(column, 0), lastColumn),
  });
  switch (move.kind) {
    case "step":
      return hold(cell.row + move.rows, cell.column + move.columns);
    case "edge":
      return hold(
        move.rows === 0 ? cell.row : move.rows > 0 ? lastRow : 0,
        move.columns === 0 ? cell.column : move.columns > 0 ? lastColumn : 0,
      );
    case "page":
      return hold(cell.row + move.pages * Math.max(pageSize, 1), cell.column);
    case "first":
      return hold(0, 0);
    case "last":
      return hold(lastRow, lastColumn);
  }
}