---
id: "packages/active-grid/keyboard"
title: "Keyboard"
description: "Cell-level keyboard navigation, the keys, and the keyboard paths for what the pointer drags."
url: "/packages/active-grid/keyboard"
locale: "en"
revision: "8310b3d3657782f8"
---

# Keyboard

> [!NOTE]
> **The package's own guide**
>
> This guide is the package's `docs/guides/keyboard.md`, included unchanged from
> the package as it is in Foundation. Links to the package's internal design
> documents keep their text.

Cell-level keyboard navigation is **on by default since 8.0.0**. Nothing has to
be passed to get it.

The grid declares `role="grid"`, and a grid is keyboard-navigable by definition.
Shipping the role with the navigation switched off was an incorrect declaration
and a WCAG 2.1.1 (level A) failure, so the default was inverted rather than left
as an opt-in.

What has been observed of this contract, per browser and date, is recorded in
`../ACCESSIBILITY-EVIDENCE.md`.

```tsx
<ActiveGrid
  onCellActivate={(row, columnId) => {
    // Called when Enter or Space is pressed on a focused, non-editable cell.
    // Editable cells start an edit instead — see "Editing by keyboard".
  }}
/>
```

## Opting out

```tsx
<ActiveGrid enableKeyboardNavigation={false} />
```

This restores the pre-8.0.0 behaviour: the grid element gets no `tabIndex`,
cells get none either, and the cell plane cannot be reached by keyboard at all.

## Editing by keyboard

Enter on a focused **editable** cell starts the edit. This is the only
keyboard route into editing: the pointer triggers (`editTrigger="click"` and
`"doubleClick"`) hang off `onClick` / `onDoubleClick`, and a `<div role="gridcell">`
does not synthesise a click from Enter.

It works the same way in all three editing modes:

| `editMode` | Enter on an editable cell |
| --- | --- |
| `cell` | Opens that cell's editor |
| `row` | Opens the whole row for edit |
| `batch` | Opens that cell's editor |

`onCellActivate` is **not** called for editable cells: one keypress must not both
activate and edit. Non-editable cells still call it.

## Row click and cell focus coexist

`onRowClick` and `onRowDoubleClick` fire regardless of keyboard navigation.
Before 8.0.0 enabling navigation silently suppressed both, along with the
`cursor-pointer` affordance. That exclusion is gone: a row click and a cell
focus are events at different levels.

## Keyboard Shortcuts

### Grid Navigation

| Key | Action |
| --- | --- |
| Arrow Right | Move focus to next cell |
| Arrow Left | Move focus to previous cell |
| Arrow Down | Move focus to cell below |
| Arrow Up | Move focus to cell above |
| Home | Move focus to first cell in row |
| End | Move focus to last cell in row |
| Ctrl+Home | Move focus to first cell in first row |
| Ctrl+End | Move focus to last cell in last row |
| PageDown | Move focus down by one viewport height |
| PageUp | Move focus up by one viewport height |
| Enter | Activate focused cell (triggers `onCellActivate`) |
| Space | Activate focused cell (triggers `onCellActivate`) |
| Shift+F10 | Open the row context menu for the active cell |
| ContextMenu | Open the row context menu for the active cell |
| Ctrl/Cmd+C | Copy: the cell range, else selected rows, else the active cell |
| Ctrl/Cmd+A | Select all rows (not cells) |
| Ctrl/Cmd+Z | Undo the last recorded cell edit (when undo/redo is on) |
| Ctrl/Cmd+Y | Redo the last undone cell edit (when undo/redo is on) |
| Ctrl/Cmd+Shift+Z | Redo, as Ctrl/Cmd+Y |

Ctrl/Cmd+Z and Ctrl/Cmd+Y act only while undo/redo is on, which is the default
whenever `onDataCommit` is set (`enableUndoRedo`), and not in
`editMode="batch"`. They are read from a focused body cell, not from an open
editor: inside an input the browser's own text undo applies. With nothing to
undo or redo the keystroke is left to the browser. What is recorded, and how an
undo reaches the data source, is in
`editing.md`.

### Initial Focus

When the grid element **itself** is focused (via Tab) and no cell has focus:

| Key | Action |
| --- | --- |
| Arrow Down | Focus the first cell (row 0, column 0) |
| Arrow Right | Focus the first cell (row 0, column 0) |
| Enter | Focus the first cell (row 0, column 0) |

NumpadEnter reports `key: 'Enter'` and therefore behaves identically. A
modified keystroke (Ctrl, Meta or Alt) is a different action and is not this
entry point.

The container is the grid's one entry. Before a cell has been active, no body
cell and no row control is a stop of its own: the first Tab into the grid
always lands on the container, never on a first cell.

Returning to the grid after leaving it restores the last focused cell.

#### This entry point never fires for a control inside the grid

The word "itself" above is the whole contract, and it is enforced with
`event.target === event.currentTarget` (10.1.2).

A keystroke that originates from a **control** — a header menu button, a filter
input, the clear button of an active filter, a button inside a body cell — is
that control's keystroke, never the grid's. The same holds for content rendered
through a **React portal** from inside the grid, such as a filter popover or a
column menu: those sit outside the grid in the DOM, but they still bubble to the
grid through the React tree, and they are not the entry point either.

There is deliberately **no allowlist** of buttons, links, inputs, textareas,
selects, contenteditables or interactive roles. Anything that is not the grid
container itself belongs to something else, so an unknown target fails safe
without anyone having to extend a list.

The same rule governs Enter and Space inside a cell. They activate the
**cell** — see [Grid Navigation](#grid-navigation) above — only while the cell
itself holds focus. On a cell that has interactive children, that first
keystroke enters *action mode* and moves focus to the first child. From that
point both keys belong to the child: the grid does not cancel them and does not
move focus, so the child activates natively. Escape, Tab and the arrow keys
stay with the grid — they carry action mode itself.

### Row controls are reached through action mode

The controls the grid renders in every row — the selection checkbox, the
expander, and the toggle of a client or server group row — are **not** stops
in the page's Tab sequence. Tab would otherwise visit two controls per row: 20
stops on a page of ten rows, 200 on a page of a hundred. They carry
`tabIndex={-1}` and action mode reaches them:

| Key | In action mode |
| --- | --- |
| Enter / Space on cell | Focus the cell's first control |
| Space / Enter | Operate the control (toggle the selection, expand) |
| Tab / Shift+Tab | Next / previous control in the cell; past the last, back |
| Escape | Leave action mode; focus returns to the cell |

A group row, client or server, is a row of the grid's navigation like any
other: the arrow keys reach it, Enter on its first cell reaches the toggle, and
on a server group row Arrow Right and Arrow Left expand and collapse it
directly.

The "load more" row of server grouping is one too. Its cells hold no control,
so Enter or Space on any of them loads the group's next block at once, the
same action a click takes; the cells announce the keys with
`aria-keyshortcuts`, and the row is `aria-busy` while the block is on its way.
Keys pressed in that time ask for nothing more. When the block lands, the new
rows take the row's display position and the active cell keeps it, so the
focus is on the first new row and the next Arrow Down reads on.

Action mode's stops are the natively focusable elements of the cell plus every
element with a `tabindex`, including `-1`. Excluded are hidden elements,
anything under `aria-hidden="true"` (the native proxy input of a checkbox
primitive), and a control with `aria-disabled="true"`.

**A control the consumer renders in a cell** keeps whatever tab stop it
declares. To join this model and stay out of the Tab sequence, give it
`tabIndex={-1}`; action mode reaches it all the same.

With `enableKeyboardNavigation={false}` there is no action mode, and the row
controls keep their natural stop.

### Edit Mode

When an input or textarea inside a cell has focus (during editing):

| Key | Action |
| --- | --- |
| Enter | Commit the edit and move to next editable cell in the same column |
| Escape | Exit edit mode, return focus to the cell |
| *(other keys)* | Handled by the editor, not by grid navigation |

Arrow keys and other navigation keys are passed through to the editor when
editing. This prevents accidental navigation while typing.

### Input method composition

Every grid key handler that acts on Enter, Escape, Tab or an arrow key (the
cell editor, cell navigation and range selection, the container entry point,
the sortable header, the date filter inputs) returns early on a composing key
(`isComposing`, or `keyCode 229`). An open IME candidate therefore never
commits, cancels or moves anything in the grid; see
Editing § Input method editors.

## Focus Management

### Focus State

The focused cell is tracked as a `CellPosition`:

```typescript
interface CellPosition {
  rowIndex: number;
  colIndex: number;
}
```

### Visual Indicator

The active cell receives:

- `tabIndex={0}` (other cells have `tabIndex={-1}`), and the focus: the grid
  keeps the browser's focus and its own active cell on the same cell. A press
  makes the cell active on the press itself (`onMouseDown`), so the two agree
  from the first frame, also when the pointer is dragged on.
- `data-focused="true"` data attribute.
- One square mark: one pixel at 50% of `--ring` on all four sides, drawn from
  `data-focused` and the grid's `data-body-focus`, not from the browser's
  `:focus`. A click and an arrow key make a cell active alike, and one mark
  serves both. When the focus leaves the grid the mark goes; a menu the grid
  opened keeps it. An editor or a control inside the cell that takes the focus
  shows its own focus, and the cell mark gives way. Inside a cell range the
  active cell has no mark of its own; see
  [Cell selection](#cell-selection-range-select).

A sortable header label is the opposite case: it shows the browser's keyboard
focus ring for keyboard use only. A label the pointer focused carries
`data-pointer-focus` and shows no ring, also while a held Shift (multi-sort)
auto-repeats; the first key that is not a modifier, or leaving the label,
removes the attribute.

The grid element itself marks keyboard focus with its own border at 50% of
`--ring` (`focus-visible:border-ring/50`), for the moment focus rests on the
grid and not on a cell: on entry, and while focus is parked there (see
[Focus continuity](#focus-continuity-when-the-active-cell-leaves-the-dom)).
Only one element holds focus, so the container mark and the cell mark never
draw together. The styling contract is in
`styling.md`.

### Cell Registration

Each cell registers its DOM element via `registerCellRef`. This allows the
keyboard handler to programmatically focus the correct element when
navigation occurs.

```typescript
// Internal: each BodyCell calls this on mount/unmount
registerCellRef(rowIndex, colIndex, element);
```

## Scroll-to-Focus (Virtualized Grids)

When keyboard navigation moves focus to a row that is outside the visible
viewport (in a virtualized grid), the grid automatically scrolls to bring
the target row into view:

1. The new `CellPosition` is set in state.
2. `useKeyboardNavigation` checks if the cell's DOM element exists via
   `cellRefs`.
3. If the element exists: `element.focus()` +
   `scrollIntoView({block: 'nearest'})`.
4. If the element does not exist (virtualized off-screen):

   - Call `virtualizer.scrollToIndex(centerRowIndex, {align: 'auto'})` to
     scroll the target row into view.
   - Look for the newly rendered cell on each animation frame, for up to
     fifteen frames, and focus it once it is registered.

The hook receives `scrollToIndex` from the virtualizer via a ref threaded
through the grid's view layer. The `absoluteIndex` from
keyboard navigation is converted to a center-row index by subtracting the
top pinned-row count.

The frame loop is needed because the virtualizer settles its scroll over
several frames when row heights are measured, and the cell ref is only
registered once its row has mounted.

### Why `virtualizer.scrollToIndex`

Previous versions estimated scroll position as `rowIndex * rowHeight`,
which assumed fixed row heights. This broke after ~50 rows with dynamic
heights (`estimateSize`, expanded rows, `autoHeight` cells).
`virtualizer.scrollToIndex` uses the virtualizer's internal measurement
cache for accurate positioning regardless of row-height variation.

The `scrollElement` is only connected to the keyboard handler in
virtualized mode (when `height` is a number and row-count exceeds the
threshold).

## Focus continuity: when the active cell leaves the DOM

The grid removes elements on its own: a virtualized row or column leaves the
mounted window when the reader scrolls it away, and a control inside a cell can
be replaced by a render. The browser then hands focus to `<body>`, and a
keyboard reader would have no position left. The grid keeps that position:

- **Focus parks on the grid.** When the element that held focus leaves the
  DOM, focus moves to the active cell if it is still mounted, and otherwise
  to the `role="grid"` element. A cell the grid replaces while it holds the
  focus (a row whose content changes under the active position) takes the
  same path. It does not scroll: the reader's scroll
  position stays theirs. The active cell (its row and column) is unchanged.
- **Focus returns by itself.** While focus is parked on the grid and the
  active cell mounts again, for instance because the reader scrolls it back
  into view, focus moves back to that cell.
- **The next key resumes.** On the parked grid, an arrow key, Home, End,
  PageUp or PageDown moves from the active cell, scrolling it back into view;
  Enter, Space and F2 bring the active cell back without activating it. If the
  active cell no longer exists (a filter or a page change left fewer rows or
  columns), its coordinate is clamped to the nearest cell of the current
  model.
- **Returning by Tab** lands on the active cell when it is mounted, and
  otherwise on the grid, where the next key resumes as above.

This holds for row and column virtualization alike, and for any other way
the active cell's element disappears. The active-cell mark is only drawn on a
cell that really holds focus; there is no mark on a cell focus has left.

The recovery has two triggers: the browser's `focusout` of a removed
element, and a registered cell that is unregistered while it holds the focus.
The second does not depend on the engine dispatching that event.

## PageUp / PageDown

PageUp and PageDown move focus by approximately one viewport height:

```
pageSize = Math.floor(scrollElement.clientHeight / rowHeight)
```

- **PageDown**: `newRow = Math.min(currentRow + pageSize, lastRow)`
- **PageUp**: `newRow = Math.max(currentRow - pageSize, 0)`

If the scroll element is not available (e.g., auto-height mode), a default
viewport of 400 px is used.

## Tab Order

The tab order through the grid follows this sequence:

1. Skip link (visually hidden until focused).
2. Toolbar (search input, action buttons).
3. The grouping panel, when grouping is enabled.
4. The `role="grid"` element itself.
5. Column headers and the filter row.
6. The active cell, once there is one — and nothing else in the body.
7. Pagination (page buttons, page-size selector).

The tab stop is the `role="grid"` element. It is **not** the inner
`.grid-scroll-container`: a focusable element with no role sitting directly
inside `role="grid"` is an `aria-required-children` violation, which axe rates
critical. Focus-visible styling sits on that same element, in the package
stylesheet (`styles/chrome.css`): its own border at 50% of the ring, no ring.

```css
.grid-frame--focusable:focus-visible {
  border-color: color-mix(in oklab, var(--grid-ring) 50%, transparent);
}
```

### The skip link is an exit

The body contributes at most one stop, the active cell, but the header still
has a sort label, a column menu and filter controls per column. The skip link
in front of the grid gets past them. It lands on a sentinel placed directly
after the `role="grid"` element, so the header, filter row and body are skipped
while pagination and the batch toolbar stay reachable.

Before 8.0.0 the link pointed at the `role="grid"` element and was labelled
"skip to grid content". It did nothing: the target had no `tabIndex`, and a
fragment link to a non-focusable element does not move focus. Working, it would
still only have duplicated what the next Tab already did.

## ARIA Roles

Active Grid applies semantic ARIA roles for screen-reader accessibility:

| Element | ARIA Role |
| --- | --- |
| Grid container | `role="grid"` |
| Header row | `role="row"` |
| Header cell | `role="columnheader"` |
| Body row | `role="row"` |
| Body cell | `role="gridcell"` |
| Footer row | `role="row"` |
| Selection checkbox | `aria-label="Select row"` / `"Select all"` |
| Header checkbox | `aria-checked`: `false` / `mixed` (part of the page) / `true` |
| Expander toggle | `aria-label="Expand"` / `"Collapse"`, `aria-expanded` |
| Group toggle | `aria-label="Expand group"` / `"Collapse group"`, `aria-expanded` |
| Sort indicator | `aria-sort="ascending"` / `"descending"` |
| Empty-state message | One `row` with one `gridcell` holding the message |
| Body while loading | `rowgroup` with `aria-busy="true"` |

**What the grid says it can select** follows what the reader can select:
`aria-multiselectable="true"` with `enableCellSelection`, and with row
selection that is not limited to one row (`enableRowSelection` with
`enableMultiRowSelection` left on). A single-row grid without cell selection,
and a grid without selection, state nothing; that is the attribute's default.
A selected row carries `aria-selected="true"` in every case.

**Every body row carries `aria-rowindex`**, counted over the presented rows
after the rows above the body: data rows, client group rows, and the group
and "load more" rows of server grouping alike. In a virtualized body the index
is the mounted row's place in the whole list, not in the window. The rows
above the body are the header levels plus the filter row when it renders
(`showFilterRow`, or a filterable column with `meta.filterType`); a grid
without a filter row reserves no place for one. `aria-rowcount` is those rows
plus the total row count. The header, the body sections and the grid read one
decision for whether the filter row exists. The index is an ARIA position;
keyboard navigation keeps its own coordinates.

The `role="grid"` element owns rows and row groups only. The grouping panel
(the chips of the active grouping, with their remove buttons) is therefore
rendered directly above it, as the top of the same card, and not inside it.

### Accessible names carry the column

Controls that exist once per column are named after that column, so a
screen-reader user can tell them apart. The column's label is `meta.label`
when the column declares one, else a string `header`, else the column id. A
column whose `header` is a render function has no text the grid can read, and
should declare `meta.label`.

| Control | Name (English defaults) |
| --- | --- |
| Filter-row value control | `Filter value for {column}` |
| ... with a chosen value | `Filter value for {column}: {value}` |
| Funnel button, and its popover | `Filter {column}` |
| Clear button | `Clear filter {column}` |
| Condition select in the popover | "Condition" |
| Value fields in the popover | "Value", or "From" / "To" for a range |
| Column menu button | `Column options for {column}` |
| Sort label in a multi-column sort | `{column} sort order {n}` |
| Cell editor | `{column}` |
| Server group row | shows the grouped column's label, not the field id |

The words come from the host's translator through the `grid.*` keys
(`grid.filter.valueFor`, `grid.filter.valueForWithValue`,
`grid.filter.openFilterFor`, `grid.filter.clearFilterFor`,
`grid.filter.condition`, `grid.filter.value`, `grid.filter.rangeFrom`,
`grid.filter.rangeTo`, `grid.header.optionsFor`, `grid.sort.priority`,
`grid.filterBuilder.column`); the column label and the value are variables,
never concatenated English.

## Column order and width without a pointer

Dragging a header and dragging a column edge are pointer gestures. The column
menu (the header's options button) carries their keyboard alternative:

| Menu item | Shown when | One activation |
| --- | --- | --- |
| Move left | `enableColumnReordering` | One valid position to the left |
| Move right | `enableColumnReordering` | One valid position to the right |
| Increase width | `enableColumnResizing`, and the column allows it | 10 px wider, up to the column's maximum |
| Decrease width | `enableColumnResizing`, and the column allows it | 10 px narrower, down to the column's minimum |

- **The menu stays open** after these items, so a column travels or grows by
  repeating Enter on the same item. Escape closes the menu and returns focus
  to the column's options button. Every step is applied as it is taken, so
  leaving is always safe and the order and widths stay valid.
- **A move follows the rules of a drop.** It never crosses a pinned boundary,
  never leaves its header group, skips hidden columns, and never passes a
  grid-owned column (selection, row number, expander, actions). A direction
  with no valid position is disabled; a column that can move nowhere shows no
  move items.
- **A width goes through the same acceptance as a drag**, which holds it
  between the column's bounds (`minSize`, `maxSize`, else the defaults), and a
  changed width locks the column exactly as the end of a drag does.
- **Every step is announced** through the grid's polite live region: the
  column's new position ("Name moved to position 2 of 5", the position a
  screen reader meets as `aria-colindex`), its new width, or that it is at its
  minimum or maximum width. A pointer drag announces nothing.

The words come from the host's translator: `grid.move.label`,
`grid.move.left`, `grid.move.right`, `grid.layout.increaseWidth`,
`grid.layout.decreaseWidth`, `grid.announce.moved`, `grid.announce.resized`,
`grid.announce.atMinWidth` and `grid.announce.atMaxWidth`, with the column
label, position, total and width as variables.

A column whose header menu is suppressed (`meta.headerMenu.enabled: false` or
`meta.suppressHeaderMenuButton`) has no keyboard path to these steps; keep the
menu on a column the reader should be able to move or resize.

## The row context menu and the active cell

A right-click on a body cell and Shift+F10 (or the ContextMenu key) on the
active cell open the same menu, for the same target:

- **Outside a range, the target cell becomes the active cell.** A
  right-click focuses the cell it lands on; the grid's position follows, so
  the next arrow key moves from that cell and not from the cell that was
  active before.
- **Inside a range, nothing moves.** The range, the active cell (the range's
  anchor) and the focus stay where they are; the menu is about the range, and
  on close the focus returns to the active cell.
- **With `enableCellSelection`**, the menu is opened for one of three
  contexts, decided when it opens. Inside a range of more than one cell it is
  a *range* context and every range stays as it was. Outside every range, all
  ranges are cleared and it is a *cell* context. A single selected cell is the
  active cell, not a range, so it is a cell context too. Row selection is never
  changed by opening the menu.
- **Without `enableCellSelection`** it is the row menu, as it always was.
- **The row's actions stay.** A source row keeps its own menu (the
  `contextMenu` builder, edit, pin, export, reset) in every context.
- **A generated row.** A client group row offers the grid's copy of what its
  cells show, and only that, when cell selection is on: no builder, no edit,
  no pin, and no record is invented for it. A server group row opens no grid
  menu.
- **Focus.** The open menu holds focus, so the arrows move in the menu.
  Opened by Shift+F10 or the ContextMenu key, the menu focuses its first
  item; opened by the pointer, it focuses the menu itself. The arrows wrap,
  Home and End reach the first and last item, typing a letter jumps to the
  next item that starts with it; ArrowRight opens a submenu (ArrowLeft in
  rtl) and Escape closes one level. Tab closes the whole menu. A disabled
  item stays reachable and reads its reason. Under a coarse pointer every item
  is at least 44 px high. Active Charts behaves the same (ADR-058). When
  the menu closes, focus returns to the active cell, where the next key
  continues. An action that opens a dialog follows the contract below.

The keyboard path is the grid's own and does not depend on the browser turning
Shift+F10 into a `contextmenu` event; the browser's own event for that
keystroke is suppressed, so no second menu opens. `rowMenu={{enabled: false}}`
removes the grid menu altogether; the active cell still follows a right-click.

### Copy actions, named by scope

Every copy item says what it copies, so no single "Copy" means different
things depending on state the reader cannot see:

| Context | Items |
| --- | --- |
| range | Copy selection · Copy selection with headers |
| cell | Copy cell · Copy row · Copy row with headers · Copy selected rows (N) |
| row | Copy selected rows (N) and with headers, or Copy row and with headers |

"Copy cell" appears on a data column only, and "Copy selected rows (N)" only
while rows are selected. On a generated row the menu holds the range or cell
items alone.

**The Ctrl+C hint sits on one item: the one that does what Ctrl/Cmd+C does
right now** (a cell range first, then the selected rows, then the active
cell). It is exposed as `aria-keyshortcuts`, and its visible text is not part
of the item's name. Where Ctrl+C would copy something no item offers (the
active cell in a row context), no item carries it. "Copy selection" writes
exactly the block Ctrl+C writes.

## Focus Restoration after Dialogs

When opening a dialog or modal from a grid context menu, some dialog
implementations return focus to `document.body` instead of the grid when
closing. Use the `restoreFocusRef` prop to manually restore focus to the
last active cell:

```tsx
const restoreFocusRef = useRef<(() => boolean) | null>(null);

<ActiveGrid
  gridId="people"
  mode="client"
  data={people}
  columns={columns}
  restoreFocusRef={restoreFocusRef}
/>;

// In your dialog close handler:
function handleDialogClose() {
  restoreFocusRef.current?.(); // true when focus was restored
}
```

This pattern is the recommended escape hatch when automatic focus
restoration via `handleContainerFocus` does not fire (e.g., focus lands on
`document.body` instead of the grid container).

## Cell selection (range-select)

Cell-range selection is off by default. Turn it on with `enableCellSelection`;
it is independent of row selection (the checkbox column).

```tsx
<ActiveGrid
  gridId="invoices"
  data={invoices}
  columns={columns}
  enableCellSelection
  onCellSelectionChange={(ranges) => {
    // `ranges` is the explicit selection: CellRange[], every range; `[]` when
    // only the active cell is selected.
    console.log('Selected ranges:', ranges);
  }}
/>
```

### Interaction

Two things, each with one owner. The **active cell** is the keyboard
position, and the anchor of every extension. A **range** is an explicit
selection: a drag that reached a second cell, a Shift extension or a Ctrl/Cmd
addition. A plain click stores no range; the one cell it selects is the active
cell, which shows the one-cell look (a square mark and a small cue).

| Input | Active cell | Ranges |
| --- | --- | --- |
| Click | The clicked cell | Ended; nothing is stored |
| Click on the active cell | Unchanged | Unchanged: nothing changes at all |
| Drag | The pressed cell (anchor) | One range from the anchor to the cell under the pointer |
| Shift+click | Stays (anchor) | One range from the anchor to the clicked cell |
| Shift+Arrow, Shift+Home/End | Stays (anchor) | The far end moves and is brought into view |
| Ctrl/Cmd+click or drag | The clicked cell | Added; the earlier selection, the active cell included, stays |
| Ctrl/Cmd+Shift+click | Stays (anchor) | The last range is replaced by one from the anchor to the cell |
| An arrow that moves the cell | Moves | Ended |
| Escape | Stays | Ended; without a range, nothing happens |
| Starting an edit | Stays | Ended |
| Ctrl/Cmd+C | – | Copies the selection (see below) |
| Right-click, Shift+F10 | See the menu section | See [The row context menu](#the-row-context-menu-and-the-active-cell) |

After Escape the grid looks exactly as after a click on the active cell.
When a Shift+arrow takes the far end so far that the anchor leaves the mounted
window of a virtualized grid, the focus parks on the grid (see
[Focus continuity](#focus-continuity-when-the-active-cell-leaves-the-dom)) and
the selection keys keep working from there: Shift+arrow extends on, Ctrl/Cmd+C
copies, Escape ends the range and brings the active cell back, and Shift+F10
brings it back and opens its menu.

Ctrl+A selects all **rows**, not cells. There is no Ctrl+Shift+Arrow to the
edge of the data, and a drag does not scroll the grid.

**Several ranges are one selection.** Everything that acts on "the
selection" (Ctrl+C, the menu's copy items) acts on every range together; there
is no active range.

**A range is a set of display positions.** It ends when a sort, filter, row
pin or column change puts other rows or columns at the positions it covers, so
a copy never takes data that moved in after the selection was made.

**The callback.** `onCellSelectionChange` receives the explicit ranges,
`CellRange[]`, after each finished change: once at the end of a drag, not on
every pointer move, and `[]` when the selection ends. A click reports nothing:
it changes the active cell, not the ranges.

**For assistive technology** every cell in an explicit range carries
`aria-selected="true"`, and the grid declares `aria-multiselectable="true"`.
Only `true` is stated, so a row's own selection is never contradicted. The
active cell is a separate fact (`data-focused`, the one `tabIndex=0`, and the
focus); the one-cell look of a clicked cell is focus, not selection, so it
carries no `aria-selected`.

**Turning cell selection off ends the selection.** When `enableCellSelection`
goes from `true` to `false`, every range is cleared and
`onCellSelectionChange` reports `[]` once. Nothing of the range stays behind
out of sight: no cell is painted or `aria-selected`, Ctrl+C and the menu's
copy items fall back to the selected rows and then the active cell, a custom
menu builder receives a `row` context without `selection`, and turning the
feature on again brings no range back. The row selection and the active cell
are not touched.

**What it looks like**: the active cell outside a range has one square mark
and, with cell selection on, a small square cue on its bottom-right corner. A
range has one light fill, one thin outline on its outer edges and one cue on
the last range's bottom-right corner; the active cell inside it is left
unfilled (the window) and has no mark of its own. When the focus leaves the
grid the active cell's look disappears and a range stays. Nothing moves
content. The contract is in `styling.md`.

### Copy from cell-selection

Ctrl/Cmd+C, and the menu's "Copy selection", copy the enclosing rectangle of
every range; cells outside the ranges are left empty, and the grid's own
columns (selection, expander, actions) are left out. "Copy selection with
headers" adds one header row with the columns' labels (`meta.label`, else a
string `header`, else the id), also when there are several ranges. Values are
the presentation form, through the clipboard TSV boundary; see
`export.md` § Clipboard is a presentation copy.

### Copy feedback (opt-in toast)

Since v2.0, active-grid no longer triggers a toast on copy-success/error
automatically. Copy still works — data lands in the clipboard — but no
visual feedback is shown by default. Consumers wire their own UX feedback
via two `meta` callbacks:

```typescript
import type {GridMeta} from '@workspace/active-grid';

interface GridMeta {
  // ... other fields ...
  onCopySuccess?: (count: number) => void;
  onCopyError?: () => void;
}
```

Both are called once per copy, from the row context menu's copy items and
from Ctrl/Cmd+C. `onCopySuccess` receives the number of rows copied: the
copied rows, the rows a cell range spans, or 1 for a single cell.

#### Pattern 1 — Per-grid, inline

The simplest pattern. Each grid wires its own toast directly:

```tsx
import {ActiveGrid} from '@workspace/active-grid';
import {toast} from './notifications'; // host-owned notification adapter

<ActiveGrid
  gridId="users"
  data={users}
  columns={columns}
  meta={{
    onCopySuccess: (count) => toast.success(t('grid.copy.success', {count})),
    onCopyError: () => toast.error(t('grid.copy.error')),
  }}
/>;
```

Good for one-off grids or when each grid has a different toast style.
For 5+ grids you start repeating yourself — see Pattern 2.

#### Pattern 2 — Shared helper-hook

DRY-ify the boilerplate across many grids. Place a hook in your app's
shared grid-utils:

```tsx
// host/grid/use-grid-copy-meta.ts
'use client';
import {useMemo} from 'react';
import {useTranslations} from '@workspace/active-i18n/react';
import {toast} from './notifications'; // host-owned notification adapter

export function useGridCopyMeta() {
  const t = useTranslations();
  return useMemo(
    () => ({
      onCopySuccess: (count: number) =>
        toast.success(t('grid.copy.success', {count})),
      onCopyError: () => toast.error(t('grid.copy.error')),
    }),
    [t],
  );
}
```

Then at every grid call-site:

```tsx
import {useGridCopyMeta} from '@/lib/grid/use-grid-copy-meta';

function MyGrid() {
  const copyMeta = useGridCopyMeta();
  return (
    <ActiveGrid
      gridId="my-grid"
      data={data}
      columns={columns}
      meta={copyMeta}
    />
  );
}
```

If the grid already passes other `meta` fields, spread:

```tsx
meta={{ ...copyMeta, ...myOtherMeta }}
```

#### Pattern 3 — App-level wrapper component

Max DRY. Create a thin `<AppGrid>` wrapper that injects copy-meta defaults:

```tsx
// host/grid/app-grid.tsx
'use client';
import {ActiveGrid, type GridProps} from '@workspace/active-grid';
import {useGridCopyMeta} from './use-grid-copy-meta';

export function AppGrid<TData>(props: GridProps<TData>) {
  const copyMeta = useGridCopyMeta();
  return <ActiveGrid {...props} meta={{...copyMeta, ...props.meta}} />;
}
```

Then in every grid replace `<ActiveGrid>` with `<AppGrid>`:

```tsx
import {AppGrid} from '@/lib/grid/app-grid';

<AppGrid gridId="my-grid" data={data} columns={columns} />;
```

Most app-wide. Best when you have many grids and want a single point of
control for cross-grid instance defaults beyond just copy (e.g. default
`height`). Presentation, translator and behaviour configuration do not need a
wrapper: they come from `<GridDefaults>` (see
`../api/columns.md` § GridDefaults).

#### Pattern 4 — Centralized event-bus / log-only

If you don't want toasts but want telemetry, point the callbacks at your
log helper:

```tsx
import { logEvent } from "@/lib/telemetry";

meta={{
  onCopySuccess: (count) => logEvent("grid.copy.success", { count }),
  onCopyError: () => logEvent("grid.copy.error", {}),
}}
```

Or use both — toast AND log:

```tsx
meta={{
  onCopySuccess: (count) => {
    toast.success(t("grid.copy.success", { count }));
    logEvent("grid.copy.success", { count });
  },
  onCopyError: () => {
    toast.error(t("grid.copy.error"));
    logEvent("grid.copy.error", {});
  },
}}
```

#### When the callbacks are not provided

Copy still works — data goes to the clipboard via
`navigator.clipboard.writeText()`. The user just doesn't see UX feedback.
For external adopters who manage UX globally (e.g. via an error-boundary
or a global toast-bus), this is the cleanest default.

### Interaction with row selection

Cell selection and row selection are independent. Both can be active
simultaneously: a row can be selected (checkbox) and a cell-range within
that row can also be selected. Operations that need an explicit choice
(e.g., bulk delete) operate on row-selection. Ctrl+C copies an explicit cell
range if there is one, otherwise the selected rows, otherwise the active cell:
a clicked cell is the active cell and not a range, so the selected rows go
before it. The context menu names both scopes as separate items. Opening the
menu never changes the row selection.

### Limitations

- Cell selection is **client-state only** — no URL-sync, no localStorage
  persistence.
- The range cannot span across paginated boundaries.
- For server-mode grids, only the currently-loaded rows can be in the
  range.
- The selection is internal: there is no controlled prop and no imperative
  API to set it.
- **No paste** (Ctrl+V) and **no fill handle or drag-to-fill**: a range can be
  copied, not written. Both are open roadmap work.

## Known Limitations

- **No column-header navigation:** keyboard navigation operates within the
  body grid only. Header cells are not part of the navigation grid. Their
  controls — the sort label, the column menu, the filter row's inputs, funnel
  and clear button — are reached with Tab and keep their own native keyboard
  behaviour; the grid does not intercept it.
- **No Tab-between-cells:** Tab does not move between cells. Tab moves to
  the next focusable element in the page (pagination, next component). Use
  arrow keys for cell-to-cell navigation.
- **Frame delay:** scroll-to-focus in virtualized grids waits for the target
  cell over animation frames (up to fifteen). During very fast arrow-key
  repeats, focus may briefly lag behind the visual scroll position.
