Input Grid

A rows × cols grid of typed text inputs. Each cell is a real <input> so native form semantics, autocomplete, autofill, and IME composition work out of the box. Tab walks reading-order; arrow keys move between cells; Home/End jump to row ends; PageUp/PageDown jump to column ends.

Customize
Shape
3x3
off
0.50rem

Installation

npx shadcn@latest add https://craftbits.dev/r/input-grid.json

Usage

import { useState } from "react";
import { InputGrid } from "@craft-bits/core";
 
const [values, setValues] = useState<string[][]>([
  ["", "", ""],
  ["", "", ""],
]);
 
<InputGrid
  rows={2}
  cols={3}
  values={values}
  onValuesChange={setValues}
  placeholder="0"
  aria-label="2x3 matrix"
/>

Uncontrolled — pass defaultValues and skip values/onValuesChange:

<InputGrid
  rows={3}
  cols={3}
  defaultValues={[
    ["1", "0", "0"],
    ["0", "1", "0"],
    ["0", "0", "1"],
  ]}
  aria-label="Identity matrix"
/>

Per-column input type + label, per-cell disabling:

<InputGrid
  rows={4}
  cols={2}
  columnsConfig={{
    0: { label: "Key", type: "text", placeholder: "name" },
    1: { label: "Value", type: "number", placeholder: "0" },
  }}
  cells={{
    "0:0": { disabled: true },
  }}
  aria-label="Settings"
/>

Understanding the component

  1. Cell anatomy. Every cell is a native <input> wrapped in a role="gridcell" div. The grid container is role="grid" with aria-rowcount / aria-colcount reflecting the label tracks when shown.
  2. Override layering. Per-cell wins over per-row wins over per-column wins over grid defaults. disabled is the only field that ORs upward — disabling the grid disables every cell regardless of per-cell config.
  3. Controlled vs uncontrolled. Pass values + onValuesChange for controlled. Pass defaultValues for uncontrolled — internal state projects onto the current rows × cols shape on resize so existing input is preserved when the grid grows or shrinks.
  4. Keyboard model. Tab walks reading-order (left-to-right, top-to-bottom). Arrow keys move one cell at a time and wrap within the row (left/right) or column (up/down). Home / End jump to the first/last enabled cell in the row; Ctrl+Home / Ctrl+End and PageUp / PageDown jump to the first/last enabled cell in the column. Disabled cells are skipped during navigation.
  5. Labels. rowsConfig[r].label renders a left header column; columnsConfig[c].label renders a top header row. Either auto-shows when any row or column carries a label; force-show or force-hide via showRowLabels / showColumnLabels.

Props

PropTypeDefaultDescription
rowsnumberrequiredNumber of rows.
colsnumberrequiredNumber of columns.
valuesreadonly (readonly string[])[]Controlled value matrix. Pair with onValuesChange.
defaultValuesreadonly (readonly string[])[]Uncontrolled initial value matrix.
onValuesChange(values: string[][]) => voidFired on any cell change.
typeHTMLInputTypeAttribute'text'Default input type for every cell.
placeholderstringDefault placeholder for every cell.
disabledbooleanfalseDisable every cell.
rowsConfigRecord<number, InputGridRowConfig>Row-level overrides (label, placeholder, disabled, type).
columnsConfigRecord<number, InputGridColumnConfig>Column-level overrides (label, placeholder, disabled, type).
cellsRecord<string, InputGridCell>Sparse per-cell overrides keyed by 'row:col'.
showRowLabelsbooleanautoForce the row-label column on/off.
showColumnLabelsbooleanautoForce the column-header row on/off.
gapstring'0.5rem'Gap between cells (CSS length).
minCellWidthstring'0'Minimum width per cell (CSS length).
aria-labelstringAccessible name for the grid.
classNamestringMerged onto the rendered root <div>.

Accessibility

  • Renders <div role="grid"> with aria-rowcount / aria-colcount covering label tracks. Each row of cells is a role="gridcell" wrapper around a native <input>. Optional row/column labels use role="rowheader" / role="columnheader".
  • Always pass aria-label (or aria-labelledby) on the grid so screen readers announce its purpose.
  • Each cell computes an accessible name as aria-label || gridLabel — rowLabel — colLabel || 'Row N, column M'. Override per cell with cells['r:c']['aria-label'].
  • Keyboard: arrow keys wrap within row/column and skip disabled cells; Tab walks reading-order; Home/End scope to the row, PageUp/PageDown (and Ctrl+Home / Ctrl+End) scope to the column.
  • Focus is visible via a 2-px accent ring keyed on --cb-accent. Disabled cells drop from tab order and from arrow navigation.
  • Native <input> autocomplete, autofill, IME, copy/paste, and form submission all work — the component does not steal these behaviors.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/interaction/InputGrid.tsx). The source path was empty in AlgoFlashcards — implemented from concept. Lesson chrome (typed-fact bindings, per-step answer scoring) was stripped; the primitive is a generalized typed input matrix with controlled+uncontrolled values, layered overrides, and full keyboard navigation.