Foundations
Grid navigation
Moving the active cell through a grid by the keyboard, to its neighbours and to its edges.
MarkdownIn 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.
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
| Key | Moves the active cell |
|---|---|
| Arrow keys | One cell |
| Command with an arrow key | To the edge of the grid in that direction |
| Home, End | To the first or last cell of the row |
| Command-Home, Command-End | To the first or last cell of the grid |
| Page Up, Page Down | By 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.jsonSource
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);
}
}