Skip to content

Behaviors

Keyboard navigation, selection, editing, and clipboard behaviors.

W3C APG Compliance

We follow W3C ARIA Authoring Practices Guide — Grid Pattern for data grids.

AspectW3C SpecOur implementation
Grid is single tab stopRequiredRoving tabindex
Arrow navigation without wrapRequired for data gridDefault, wrap optional
Shift+Arrow extends selectionRecommendedSupported
Enter/F2 for edit modeRecommendedSupported
Escape exits editRecommendedSupported
aria-selected for selectionRequired if selectionSupported
aria-readonly for editabilityRequired if editingSupported
aria-rowindex/colindex for virtualRequired if virtualizedUserland applies

Note: W3C distinguishes "data grid" from "layout grid". We implement data grid (spreadsheet-like). Layout grid has different behaviors (wrap allowed).


Focus & Interaction Modes

Responsibility

Tracks which single element has keyboard focus and the current interaction mode. The mode determines how keyboard input is interpreted.

State

typescript
focus: {
  target: CellRef | HeaderRef | null;
  mode: "navigation" | "edit" | "interactive";
}

// Editing state (only set when mode === 'edit')
draft: unknown | null;
original: unknown | null;

Focus Modes

ModeDescriptionArrow KeysEnter/F2Escape
navigationDefault. Grid keyboard navigation activeMove between cellsEnter edit/interactiveClear selection
editInline text editing in focused cellCursor in inputCommit + exitCancel + exit
interactiveComplex interactive active (combobox, etc)Interactive-specificInteractive-specificExit to navigation

Keyboard by Mode

KeyActionBoundary behavior
Focus cell to rightStop at last column
Focus cell to leftStop at first column
Focus cell belowStop at last row
Focus cell aboveStop at first row
HomeFocus first cell in row
EndFocus last cell in row
Ctrl+HomeFocus first cell in grid
Ctrl+EndFocus last cell in grid
Page DownFuture (Focus N rows below)Not implemented in v1
Page UpFuture (Focus N rows above)Not implemented in v1
TabExit gridFocus next element in document
EscapeClear selection
EnterEnter edit modeIf cell is editable
EnterEnter interactive modeIf cell is composite
F2Enter edit modeIf cell is editable
F2Enter interactive modeIf cell is composite
TypingEnter edit mode + type charIf startOnType: true

Edit Mode

KeyActionNotes
Arrow keysMove cursor in inputPassed to input element
HomeCursor to start of inputPassed to input element
EndCursor to end of inputPassed to input element
Ctrl+ASelect all text in inputNOT select all cells
Ctrl+ZUndo in inputPassed to input element
Ctrl+ArrowWord navigation in inputPassed to input element
Backspace/DeleteDelete characterPassed to input element
EnterCommit edit, exit to navigationGenerates COMMIT_VALUE
TabCommit edit, move to next cellMoves focus right
Shift+TabCommit edit, move to prev cellMoves focus left
EscapeCancel edit, exit to navigationRestores original value
F2No-opAlready in edit mode

Rationale: During edit, all keys except lifecycle keys (Enter, Tab, Escape) are passed to the input. This matches standard web form UX where arrow keys move the cursor, not navigate away.

Interactive Mode

KeyActionNotes
Arrow keysPassed to interactiveE.g., navigate dropdown
TabCycle internal focusablesExit on last element
Shift+TabCycle internal focusables (back)Exit on first element
EscapeExit to navigationMay close popup first
EnterInteractive-specificE.g., select dropdown option
Other keysPassed to interactiveInteractive handles internally

Note: Interactive mode keyboard handling is userland's responsibility. The library only manages mode transitions and passes events through.

Tab Behavior Matrix

Current ModeCell TypeTab PressedBehavior
navigationanyTabExit grid, focus next element
editTabCommit, exit, move to next cell
interactivemultipleTab (not on last)Cycle to next internal element
interactivemultipleTab (on last)Exit interactive mode, move to next cell
interactivesingleTabExit interactive mode, move to next cell

Mode Transitions

            ┌─────────────────────────────────────────┐
            │                                         │
            ▼                                         │
    ┌──────────────┐     Enter/F2/type    ┌─────────────────┐
    │  navigation  │ ──────────────────►  │      edit       │
    └──────────────┘   (editable cell)    └─────────────────┘
            │                                    │
            │ Enter/F2 (composite)        Enter/Tab │ Escape
            ▼                                    ▼
    ┌──────────────┐                      ┌─────────────────┐
    │  interactive │ ◄────────────────────│   navigation    │
    └──────────────┘     Escape/Tab out   └─────────────────┘
FromToTriggerPreconditions
navigationeditEnter, F2, or printable keyCell is editable
navigationinteractiveEnter, F2Cell is composite
editnavigationEnter (commit), Escape (cancel)
editnavigationTab (commit + move)Moves focus to next cell
interactivenavigationEscape, or Tab on last element

Interactive Mode Details

For cells with internal interactives (combobox, checkbox group, date picker, multiple links).

ConditionBehavior
Cell is compositeEnter/F2 enters interactive mode
In interactive mode, TabCycles between internal elements
In interactive mode, Tab on lastExits, returns to navigation mode
In interactive mode, EscapeExits immediately
In interactive mode, Arrow keysPassed to interactive (don't navigate grid)

Interactive vs Edit: When a cell contains a combobox or date picker, use interactive mode, not edit mode. Edit mode is for simple text inputs where draft/original tracking is needed. Interactive mode delegates all keyboard handling to userland.

Focus Actions

ActionTriggerState Changes
FOCUS_CELLClick on cellfocus.target → cell
FOCUS_HEADERClick on headerfocus.target → header
MOVE_FOCUSArrow keys (navigation)focus.target → adjacent cell
BLUR_GRIDFocus exits gridfocus.target → null, mode → navigation
ENTER_EDIT_MODEEnter, F2, typingfocus.mode → edit
EXIT_EDIT_MODEEnter, Tab, Escapefocus.mode → navigation
ENTER_WIDGET_MODEEnter, F2 on composite cellfocus.mode → interactive
EXIT_WIDGET_MODEEscape, Tab outfocus.mode → navigation
SYNC_CONTEXTrowIds/colIds changeAuto-recovery if focus/selection invalid

Grid Entry/Exit Focus

Grid is a single tab stop (W3C requirement).

ScenarioBehavior
Tab into grid (first time)Focus first cell (row 0, col 0)
Tab into grid (returning)Focus last focused cell (remembered)
Tab out of gridFocus next focusable element in document
Shift+Tab out of gridFocus previous focusable element
Grid loses focus (blur)If in edit mode: commit (if commitOnBlur)

Last focused cell: The library remembers the last focused cell. When the user tabs back into the grid, focus is restored to that cell. This improves UX for keyboard navigation.

Edge Cases

CaseBehavior
Focus on cell that no longer exists (row deleted)Focus on nearest valid cell, or null
Navigate beyond boundaryNo-op (focus doesn't move)
Empty gridFocus null, Tab passes through
Focus + sorting changes row orderFocus follows rowId (stable ID)
Focus + mode not navigation when grid blursImplicit commit/exit, then blur

Context Changes (Auto-Recovery)

When rowIds or colIds changes and the focused/selected cell becomes invalid, the library auto-recovers to maintain a valid state.

Focus Recovery

ScenarioRecovery Behavior
Focused column removedFocus moves to adjacent column (right → left fallback)
Focused row removedFocus moves to adjacent row (below → above fallback)
Both row and column removedFocus moves to nearest valid cell
All columns/rows removedFocus → null
Focus mode was editImplicit cancel, then recover
Focus mode was interactiveExit interactive, then recover

Recovery algorithm (column removal):

1. If colIds still contains focusedColId → no action
2. Find previous colIndex of focusedColId (from previous colIds)
3. Try colIds[colIndex] (same position, new column)
4. If out of bounds, try colIds[colIndex - 1] (left)
5. If still invalid, try colIds[0] (first column)
6. If colIds is empty → focus null

Implementation note: Recovery is triggered by the SYNC_CONTEXT action, dispatched when rows/columns change. This happens automatically in useGridBehavior.

Selection Recovery

ScenarioRecovery Behavior
Selected column removedSelection range shrinks to exclude invalid cols
Anchor column removedAnchor moves to nearest valid column
Entire selection invalidatedSelection cleared (ranges → [])
Partial invalidationRange contracts to valid cells only

TanStack Table Integration

When integrating with TanStack Table features (visibility, ordering, pinning), pass columns directly:

typescript
// Columns are derived reactively from TanStack Table state
const columns = table.getVisibleLeafColumns();

// useGridBehavior detects changes and dispatches SYNC_CONTEXT automatically
const grid = useGridBehavior({
  rows: table.getRowModel().rows,
  columns,
});

This ensures:

  • Hidden columns are skipped in navigation
  • Reordered columns follow new order
  • Pinned columns are navigated in visual order

ARIA

AttributeWhereValue
tabindex="0"Focused cellSingle tab stop
tabindex="-1"Other cellsNavigable via arrow
role="grid"Container
role="gridcell"Data cells
role="columnheader"Header cells

Header Navigation

Headers are part of the navigable grid region. Arrow navigation connects headers to the data cells:

ActionBehavior
Click on headerFocuses header (FOCUS_HEADER)
ArrowDown from headerFocus first data row, same column
ArrowUp from row-0Focus header in same column
ArrowLeft/Right on headerNavigate between headers
Home on headerFirst header
End on headerLast header
Ctrl+Home on headerFirst cell (row-0, col-0)
Ctrl+End on headerLast cell
ArrowUp on headerNo-op (no row above headers)

Note: Headers receive the same keyboard handler as cells, enabling consistent navigation. The onKeyDown prop from getHeaderProps delegates to the grid keyboard handler.

Generated Effects

ActionEffects
FOCUS_CELL, MOVE_FOCUSFOCUS_ELEMENT, SCROLL_INTO_VIEW, ANNOUNCE
FOCUS_HEADERFOCUS_ELEMENT, SCROLL_INTO_VIEW, ANNOUNCE
BLUR_GRIDNone

Selection

Responsibility

Tracks which cells are selected. Handles click, Shift+click, Ctrl+A, Shift+Arrow.

State

typescript
selection: {
  ranges: SelectionRange[]
  anchor: CellRef | null
}

SelectionRange = { start: CellRef, end: CellRef }
  • ranges: v1 supports single contiguous range (array for future extensibility)
  • anchor: starting point for extension with Shift

Focus vs Selection

Separate concepts. A cell can be:

  • Focused but not selected (navigation without selection)
  • Selected but not focused (multi-selection)
  • Both (active cell in selection)
  • Neither
ScenarioFocusSelection
Click on cellCellCell (single)
Shift+ClickClicked cellRange from anchor to clicked
Arrow without ShiftNew cellUnchanged or cleared (config)
Context menu openMenuPersists on cells
Focus exits gridnullPersists (design choice)

Rationale for selection persistence:

  • Consistent with Excel/Google Sheets
  • Context menu on selection: focus goes to menu, actions operate on selection
  • Copy/paste works even after blur
  • Explicit clear via Escape or click elsewhere

W3C Keyboard

KeyAction
Shift+→Extend selection right
Shift+←Extend selection left
Shift+↓Extend selection down
Shift+↑Extend selection up
Ctrl+ASelect all cells
Shift+SpaceSelect current row
Ctrl+SpaceSelect current column
EscapeClear selection (if not in edit)

Actions

ActionTriggerTransition
SELECT_CELLClickranges → single cell, anchor → cell
EXTEND_SELECTIONShift+Click, Shift+Arrowranges → range from anchor to target
SELECT_ROWShift+Spaceranges → all cells in row
SELECT_COLUMNCtrl+Spaceranges → all cells in column
SELECT_ALLCtrl+Aranges → all cells
CLEAR_SELECTIONEscape, Click elsewhereranges → [], anchor → null

Drag Selection (Mouse)

Selection via click + drag.

PhaseEventBehavior
Startmousedown on cellanchor = cell, start tracking
Movemousemove (while dragging)Calculate cell under cursor, ranges = [anchor → current]
EndmouseupStop tracking

Implementation note: Drag tracking is userland's responsibility, not library state.

The library only receives EXTEND_SELECTION actions. It doesn't care why they're dispatched (Shift+Click, Shift+Arrow, or drag). Userland implements the mouse event loop:

typescript
// Userland implementation
const isDraggingRef = useRef(false);

const handleMouseDown = (cell: CellRef) => {
  isDraggingRef.current = true;
  dispatch({ type: "SELECT_CELL", cell });
};

const handleMouseMove = (cell: CellRef) => {
  if (isDraggingRef.current) {
    dispatch({ type: "EXTEND_SELECTION", target: cell });
  }
};

const handleMouseUp = () => {
  isDraggingRef.current = false;
};

Auto-scroll during drag: Userland responsibility.

Edge Cases

CaseBehavior
Selection includes deleted cellRange shrinks to valid cells
Extend beyond grid boundaryStop at boundary
Select all on empty gridranges = []
Shift+Click without anchoranchor = current focus, then extend
Column removed from selectionRange contracts, anchor adjusts if needed
Row removed from selectionRange contracts, anchor adjusts if needed

ARIA

AttributeWhereValue
aria-selected="true"Selected cells
aria-selected="false"Unselected cells (if selection enabled)
aria-multiselectable="true"GridIf range selection enabled

Generated Effects

ActionEffects
Any selection changeANNOUNCE (if a11y enabled)
SELECT_CELLAlso FOCUS_ELEMENT (selection moves focus)

Editing (Edit Mode Details)

Note: Editing is now part of the unified focus mode system. This section provides additional details specific to edit mode. See Focus & Interaction Modes for mode transitions.

Prerequisites

  • Cell must be focused (focus.target points to cell)
  • Cell must be editable (isEditable(cell) === true)
  • Current mode must be navigation (cannot enter edit from interactive)

Entering Edit Mode

TriggerInitial Draft Value
Enter keyCurrent cell value
F2 keyCurrent cell value
Printable characterThe typed character
Double-clickCurrent cell value

When entering edit mode:

  1. focus.mode'edit'
  2. draft → initial value (current or typed char)
  3. original → current cell value (for cancel/restore)

Exiting Edit Mode

TriggerVariantBehaviorEffect Generated
EnterCommitSave draft, stay on cellCOMMIT_VALUE
TabCommitSave draft, move rightCOMMIT_VALUE
Shift+TabCommitSave draft, move leftCOMMIT_VALUE
EscapeCancelDiscard draft, stay on cellNone
Click elsewhereCommitSave draft, focus new cellCOMMIT_VALUE
Blur (if configured)CommitSave draftCOMMIT_VALUE

Validation

Sync Validation (v1)

PhaseBehavior
During editOptional: live validation, show errors
Pre-commitvalidate(draft) called synchronously
If validCommit proceeds
If invalidCommit blocked, errors shown, stays in edit

Validation Edge Cases

CaseBehavior
Tab with invalid valueCommit blocked, focus stays, error shown
Click elsewhere with invalid valueCommit blocked, focus stays, error shown
Escape with invalid valueCancel allowed, reverts to original (valid)
Validation throwsTreated as invalid, error message shown

Actions

ActionTriggerState Changes
ENTER_EDIT_MODEEnter, F2, typingmode → edit, draft/original set
UPDATE_DRAFTTyping in editordraft updated
EXIT_EDIT_MODEEnter, Tab, Escmode → navigation, draft/original → null

Implicit Commit

When the user performs navigation actions while in edit mode:

ActionBehavior
Arrow key pressedPass through to input
MOVE_FOCUS dispatchedImplicit commit, then move
Click on different cellImplicit commit, focus new cell

Note: Arrow keys during edit mode are passed to the input element for cursor movement. They do NOT trigger MOVE_FOCUS. To navigate away, use Tab or click.

Edge Cases

CaseBehavior
Enter edit on non-editable cellAction ignored (precondition not met)
Cell deleted during editImplicit cancel, focus invalidated
onCommit throwsError bubbles up
Enter edit when already in editNo-op
Enter edit when in interactive modeNot allowed (must exit interactive first)

ARIA for Editing

AttributeWhereValue
aria-readonly="false"Editable cells
aria-readonly="true"Non-editable cells
aria-invalid="true"Cell with validation errorDuring edit

Generated Effects

ActionEffects
EXIT_EDIT_MODE (commit)COMMIT_VALUE, ANNOUNCE
EXIT_EDIT_MODE (cancel)ANNOUNCE
Validation errorANNOUNCE (error message)

Clipboard

Responsibility

Handles copy (Ctrl+C) and paste (Ctrl+V) of selected cells.

Copy

TriggerBehavior
Ctrl+C / Cmd+CSerialize selected cells

Default serialization:

  • text/plain: values separated by tab and newline (TSV)
  • text/html: HTML table (for paste in Excel/Sheets)

Userland can override serialization via config.

Paste

TriggerBehavior
Ctrl+V / Cmd+VParse clipboard, emit event

Behavior:

  1. Read clipboard (text/plain or text/html)
  2. Parse into matrix of values
  3. Emit PASTE_DATA effect with { startCell, data: string[][] }
  4. Userland decides what to do (update cells, insert rows, etc.)

Library does not modify data directly — emits event, userland applies.

Actions

ActionTriggerEffects
COPYCtrl+CWRITE_CLIPBOARD
PASTECtrl+VPASTE_DATA
DELETEDelete, BackspaceDELETE_VALUES

Edge Cases

CaseBehavior
Copy without selectionCopy focused cell
Paste area larger than gridTruncate to boundary (or userland extends)
Empty clipboardNo-op
Clipboard inaccessible (permissions)Silent no-op

Config Summary

OptionDefaultDescription
navigation.wrapfalseArrow wraps at row end
navigation.pageSize10Rows per Page Up/Down
selection.mode'range''single' or 'range'
selection.clearOnNavigationfalseClear selection on arrow without Shift
editing.commitOnBlurtrueCommit when focus exits
editing.startOnTypetrueTyping enters edit
a11y.announceNavigationtrueAnnounce focus changes
a11y.announceSelectiontrueAnnounce selection changes

Touch Support

Out of Scope for v1. Touch devices are not officially supported.

Limitations

Touch devices lack capabilities that the library relies on:

Desktop FeatureTouch LimitationImpact
Ctrl+ClickNot availableNo multi-select toggle
Shift+ClickWorks but awkwardRange selection difficult
HoverNot availableNo column menu on hover
Right-clickLong-press (inconsistent)Context menu unreliable
Physical keyboardVirtual keyboardLifecycle issues during editing

Workarounds for Userland

If you need touch support, consider:

NeedWorkaround
Multi-selectAdd checkbox column for row selection
Context menuAdd explicit menu button in row
Column actionsAdd action buttons in header
Virtual keyboardHandle blur events carefully, use inputmode

Screen Reader Compatibility

Supported Screen Readers

Screen ReaderPlatformCompatibilityNotes
NVDAWindowsPrimaryStrict ARIA interpretation
JAWSWindowsPrimaryMore lenient, some quirks
VoiceOvermacOS/iOSSecondaryNo application/forms modes
NarratorWindowsUntested
TalkBackAndroidOut of scopeTouch devices not supported

Behavior Differences

BehaviorNVDAJAWSVoiceOver
Grid role recognitionStrictLenientStrict
Application mode triggerrole="grid"role="grid"Focus on grid
Cell position announcement"row X, column Y""row X of N, col Y of M""row X, column Y"
Selection announcementOn aria-selectedOn aria-selectedLess consistent
Edit mode announcementOn focus changeOn focus changeOn focus change

Testing Requirements

Before release, test these scenarios:

  1. Navigation: Arrow key navigation announces cell content and position
  2. Selection: Selection changes are announced
  3. Editing: Enter/exit edit mode announced
  4. Validation: Errors announced via live region
  5. Headers: Column names announced when navigating

Announcements via Live Region

The library uses a visually-hidden live region for announcements:

html
<div aria-live="polite" aria-atomic="true" class="visually-hidden" />
EventAnnouncement Example
Focus cell"{content}, row {n} of {total}, column {name}"
Enter edit mode"Editing {content}"
Exit edit mode (commit)"Saved"
Exit edit mode (cancel)"Cancelled"
Selection change"{n} cells selected"
Validation error"Error: {message}"

Throttling: Rapid announcements (e.g., during keyboard navigation) are throttled to avoid overwhelming the screen reader.


Keyboard Trap Prevention

W3C Requirement

"Users must be able to navigate away from any component using the keyboard."

Escape Sequence

The Escape key always provides an exit path:

Current StateEscape Behavior
Navigation modeClear selection
Edit modeCancel edit → navigation mode
Interactive modeExit interactive → navigation mode
Interactive with popup openClose popup → still in interactive mode
Interactive, popup closedExit interactive → navigation mode

Double-Escape pattern: If an interactive has a popup (e.g., dropdown menu), first Escape closes the popup, second Escape exits interactive mode.

Tab Exit

From navigation mode, Tab always exits the grid:

ConditionTab Behavior
Navigation modeExit grid → next focusable
Edit modeCommit → move to next cell (in grid)
Interactive mode (last element)Exit interactive → move to next cell

To exit the grid from edit mode, user must first exit edit (Enter or Escape), then Tab.

Userland Responsibilities

When implementing custom interactives in cells:

  1. Always handle Escape: Close any popups, then return focus to cell
  2. Tab should cycle or exit: Don't trap Tab within a single interactive
  3. Announce state changes: Use library's announce mechanism
  4. Test with screen readers: Ensure navigation is not blocked

Anti-Patterns to Avoid

Anti-PatternProblemSolution
Modal that steals focusUser can't navigate awayUse non-modal popover
Interactive that consumes all keysUser trapped in interactiveLet Escape always exit
Auto-focus input that re-focusesUser can't Tab outOnly focus once on mount
Infinite Tab cycleTab never exits componentExit on last element

Selection During Edit Mode

Behavior

Selection is orthogonal to editing. While in edit mode:

AspectBehavior
Existing selectionPersists (visually and in state)
Selection modificationBlocked (Shift+Arrow not allowed)
Clipboard operationsOperate on selection, not edit cell

Rationale: Consistent with Excel/Google Sheets. User can copy a selection even while editing a different cell.

Edge Cases

ScenarioBehavior
Edit cell A, Ctrl+CCopy selection (may not include A)
Edit cell A, selection is [B,C]Selection persists, A not in selection
Exit edit, selection still existsSelection remains [B,C]
Edit cell in selectionSelection unchanged

Virtualization Edge Cases

Overscan Buffer

When using virtualization, maintain at least 1 row/column overscan:

OverscanProblem
0Arrow navigation to next cell fails (cell not in DOM)
1Minimum for navigation; may flash on fast scroll
2-3Recommended; smooth navigation
10+Unnecessary; performance impact

Focus and Virtualization

ScenarioBehavior
Focus cell in viewportFOCUS_ELEMENT effect focuses it
Focus cell outside viewportRef is null; focus silently skipped
Navigate to cell outsideSCROLL_INTO_VIEW first, then focus
Cell scrolls out during editImplicit cancel (cell no longer in DOM)

Scroll-Then-Focus Pattern

When navigating to a cell outside the viewport:

  1. Action dispatched (e.g., MOVE_FOCUS with PageDown)
  2. Transition calculates new target cell
  3. Effects generated: SCROLL_INTO_VIEW, then FOCUS_ELEMENT
  4. Userland intercepts SCROLL_INTO_VIEW → virtualizer scrolls
  5. React re-renders with new visible cells
  6. FOCUS_ELEMENT executes (cell now in DOM)

Timing: FOCUS_ELEMENT must wait for DOM update. The library uses requestAnimationFrame for this.

Fast Scrolling

During momentum scroll or rapid arrow key presses:

ProblemMitigation
Headers misalign with contentSync scroll positions in RAF
Focus trails behind scrollDebounce rapid navigation?
Cells flash emptyIncrease overscan buffer

Userland is responsible for virtualizer performance tuning.

Released under the MIT License.