Overview
Reference usage
ARIA data grid foundation for spreadsheet-like datasets with stable row identity, explicit column widths, keyboard navigation, row selection, cell/range selection, and pinned-column metadata.
See the tabular family boundaries. DataGrid is the interactive-grid alternative to the native Table and DataTable families; choose Table for bespoke native markup or DataTable for data-shaped native tables.
Usage
Guidance
Use When
- Rendering an interactive tabular surface that needs grid semantics instead of native table semantics.
- You need built-in row selection, cell focus, range selection, copy behavior, and inline editing.
Avoid When
- You only need a semantic read-only table — use DataTable or the Table family instead.
Keyboard navigation
DataGrid owns a single root tab stop (the role="grid" element itself) and moves a virtual "active cell" with aria-activedescendant instead of putting body cells in the tab order — the same roving-focus pattern the ARIA grid pattern describes. This table is the complete base keyboard contract; Editing and header focus, resize, and reorder layer their own keys on top of it, scoped to when a cell is editing or a header is focused.
| Key | Effect |
|---|---|
ArrowUp / ArrowDown / ArrowLeft / ArrowRight |
Moves the active cell one row or column. |
Shift+Arrow (any direction) |
Extends the selected range from the anchor cell (where the range started) to the new active cell. |
Home |
Moves to the first column of the active row. |
Ctrl/Cmd+Home |
Moves to the first cell of the grid (first row, first column). |
End |
Moves to the last column of the active row. |
Ctrl/Cmd+End |
Moves to the last cell of the grid (last row, last column). |
PageUp / PageDown |
Moves the active cell 10 rows up or down, same column. |
Tab / Shift+Tab |
Moves the active cell to the next/previous cell in row-major reading order (wrapping to the next/previous row at a row boundary), same as pressing Tab through table cells. |
Enter / Space |
Selects the active cell (extends with Shift, toggles with Ctrl/Cmd, same as a click) and updates row selection per selectionMode. On an editable, non-editing cell, Enter instead enters edit mode — see Editing. |
Escape |
Collapses the current cell range and row selection down to just the active cell. |
Ctrl/Cmd+A |
Selects every cell, and every row when selectionMode is 'single' or 'multiple'. |
Ctrl/Cmd+C |
Copies the selected cells (or just the active cell when nothing else is selected) to the clipboard as tab/newline-delimited text, and announces "Copied <n> cells" (or "Copy failed") through the live region. |
Clicking a cell mirrors the keyboard equivalents above for the cell range: a plain click moves the active cell (and, in 'single'/'multiple' selectionMode, selects that cell's row), Shift-click extends the cell range the same way Shift+Arrow does, and Ctrl/Cmd-click toggles the cell into the range the same way Ctrl+Enter does. Row selection itself only follows a plain or Ctrl/Cmd-click/Enter (replace or toggle) — Shift-click/Enter extends the cell range without changing which rows are selected, since row selection has no notion of a contiguous "range" the way cells do.
Editing
Mark a column editable: true to let its cells enter edit mode. DataGrid tracks the editing cell by stable row key plus column key — not by visual row/column index — so sorting and virtualization never lose an in-progress edit.
- The edit surface is always Cinder's own Input, never a bespoke
<input>. Itstypecomes from the column'seditType('text'or'number') when set, otherwise fromtypeofthe cell's current value — anumbervalue resolves totype="number", everything else resolves totype="text". onCellEdit(row, columnKey, value)fires on commit.valueis typed for the resolved edit type: a'number'column emits anumber, orundefinedfor an empty draft (neverNaN); every other column emits the editedstring. DataGrid never mutates therowsprop — applyvalueto your own data, as above.- A column that also supplies a custom
cellsnippet keeps rendering that snippet while editing, withediting: truein its context, instead of DataGrid's built-in Input; that column owns its own edit UI.
Resizing and reordering columns
Column resizing and reordering are both off by default so an existing grid renders unchanged. Opt in per grid with resizableColumns and reorderableColumns; a column opts out of resizing individually with resizable: false. columnSizing and columnOrder are $bindable, mirroring sortModel — DataGrid assigns them directly after a gesture and also calls onColumnSizingChange / onColumnOrderChange.
- Resizing (pointer). A resizable header cell gets a
role="separator"handle (cursor: col-resize) at its trailing edge. Dragging it live-updates the column's width, clamped tominWidth/maxWidth; the drag commits intocolumnSizing/onColumnSizingChangeon pointerup, not on every pointermove. The handle stops the pointer event from reaching the header, so dragging it never triggers a sort. - Resizing (keyboard). See the keyboard table below — Shift+ArrowLeft/Right resizes the focused header column by 10px, clamped the same way.
- Reordering (pointer). Dragging a header cell past the midpoint of a sibling shows a drop indicator and reorders on drop. Reordering is constrained to within the same pin group — a column can only change position among columns that share its
pinvalue (unpinned columns never reorder in front of or behind a pinned column, and the two pinned groups never mix). A small movement below the drag threshold is still treated as a click, so sorting a sortable column is unaffected. - Reordering (keyboard). Ctrl/Cmd+Shift+ArrowLeft/Right moves the focused header column one position within its pin group.
- Virtualized columns. Both gestures work with
virtualizeColumns— the column virtualizer is told about every resize so its measured track sizes and total width stay correct, not just the resized cell's own CSS width. - Right-to-left. The resize handle still sits at the header cell's trailing edge (which is the visual left in RTL) and pointer-drag direction is inverted automatically so dragging toward the column's interior always widens it, matching LTR. Reordering is inverted the same way — dragging toward the visual left in RTL moves a column later in
columnOrder, not earlier, so a drop always lands on the side of the target the pointer is actually over.
Keyboard and focus contract
| Key | While focused, not editing | While editing |
|---|---|---|
Enter |
Enters edit mode on an editable cell (existing Enter-for-selection behavior is unchanged on non-editable cells). | Commits and moves the active cell to the same column in the next row. |
| Double-click | Enters edit mode on an editable cell. | — |
| Any single printable character (no Ctrl/Meta/Alt) | Enters edit mode on an editable cell with that character as the initial draft. | Reaches the Input's text cursor as ordinary typing. |
Escape |
Existing grid behavior (collapses selection). | Cancels: restores the original value without calling onCellEdit and returns focus to the cell. |
Tab / Shift+Tab |
Existing grid navigation. | Commits and moves to the next cell (previous cell for Shift+Tab). |
Arrow keys, Home, End, PageUp, PageDown |
Existing grid navigation. | Reach the Input's text cursor — grid navigation does not handle them. |
| Blur (clicking elsewhere) | — | Commits. |
Focus always returns to the grid root after a commit or cancel, so role="grid" keyboard navigation (aria-activedescendant) keeps working the same way it does for click- and arrow-driven cell focus.
Header focus, resize, and reorder by keyboard
DataGrid's roving focus normally only ever lands on body cells — there is no separate "header is focused" mode until a grid opts into resizableColumns or reorderableColumns. When one of those is set:
| Key | Effect |
|---|---|
ArrowUp (from the top body row, no modifiers) |
Moves aria-activedescendant to the active column's header cell ("header focus"). |
ArrowLeft / ArrowRight (header-focused, no modifiers) |
Moves header focus to the adjacent header, in rendered order. |
Shift+ArrowLeft / Shift+ArrowRight (header-focused) |
Resizes the focused column by 10px, clamped to minWidth/maxWidth — only when resizableColumns and the column's own resizable allow it. |
Ctrl+Shift+ArrowLeft / Ctrl+Shift+ArrowRight (or Cmd on macOS, header-focused) |
Moves the focused column one position within its pin group — only when reorderableColumns is set. |
Enter / Space (header-focused, sortable column) |
Sorts, same as clicking the header. |
ArrowDown or Escape (header-focused) |
Returns focus to the body cell below the header. |
Every resize or reorder from the keyboard is announced through the same live region used elsewhere in DataGrid (for example, "Customer column resized to 190 pixels" or "Customer column moved to position 2").
This is intentionally scoped to the column of the currently active cell rather than a fully independent header roving-tabindex, since DataGrid has no such concept outside of resizing/reordering: Shift+ArrowLeft/Shift+ArrowRight on a body cell keeps its existing meaning (extending the cell selection range) and is completely unaffected — the resize/reorder shortcuts only ever fire while header focus is active.
Search
Opt in with search. DataGrid renders a toolbar — built from Cinder's own Toolbar, SearchField, and Button — directly above the grid, outside the role="grid" element. Off by default: a grid that doesn't pass search renders no toolbar, computes no matches, and starts no timers.
- Matching. 300ms after the user stops typing, DataGrid checks every column's resolved value (through the column's
getValuewhen supplied, otherwise the row property) as a case-insensitive substring match — over every row in sorted order and every column, regardless of what row/column virtualization currently has rendered. A matching cell gets thecinder-data-grid__cell--search-matchclass; the current match additionally getscinder-data-grid__cell--search-match-current. - Navigation. The toolbar's previous/next buttons, or
Enter/Shift+Enterin the search field, step through matches with wraparound. Stepping (and settling on the first match once typing stops) makes that cell the active cell and scrolls it into view through the same virtualization-adapter scroll-to-index path arrow-key navigation already uses, so it works identically whether or notvirtualizeRows/virtualizeColumnsis on. - Announcements. Match position ("Match 3 of 12") and "No matches" are announced through DataGrid's existing live region.
- Coexistence. Search recomputes from whatever
rows/columnsDataGrid currently has, so committing a cell edit or changingsortModelupdates matches and highlighting automatically — no extra wiring required. - The search field's accessible name is derived from the grid's own
aria-label(e.g. "Search Orders") and it setsaria-controlsto the grid.
Zoom
Opt in with zoom (a percentage, default 100). It multiplies row height, header height, column width, and cell text size together — everything scales as one unit, not independently.
- Range.
zoomis clamped to[50, 200]. A non-finite value (NaN,Infinity, a non-number) is ignored and DataGrid falls back to100, warning in development — the same patternrowHeightuses for an invalid value. $bindable. MirrorssortModel/columnSizing: DataGrid assignszoomdirectly after a zoom-control interaction (seezoomControlsbelow), in addition to callingonZoomChange.- Base pixels stay base pixels.
columnSizing(see Resizing and reordering columns) always stays in unscaled base pixels, whateverzoomcurrently is — only the rendered, on-screen size is scaled. A pointer resize under a non-100% zoom still commits the same base width a 100%-zoom resize would for the same visual drag distance; a keyboard resize step is always exactly 10 base px, regardless of zoom. - Virtualization.
virtualizeRows/virtualizeColumnsmeasure and scroll using the scaled row height and column widths, so virtualized and non-virtualized grids stay visually consistent at any zoom level.
Toolbar counts and zoom controls
Opt in with zoomControls to render a row count, a visible column count, and zoom in/out buttons in the same toolbar search uses — set both to combine them in one toolbar, or either alone. With neither search nor zoomControls set, DataGrid renders no toolbar DOM at all.
- Counts. The row count is the current
rows.length; the column count is the current number of visible (rendered) columns — left-pinned, unpinned, and right-pinned together. - Zoom buttons. "Zoom out" and "Zoom in" step
zoomby 10 percentage points, clamped to[50, 200]; each is disabled at its respective bound. Both have accessible names and neither isaria-controls-wired to the grid (they act on the grid as a whole, not a scoped region of it). - Announcements. Changing the zoom level announces "Zoom
<n>%" through DataGrid's existing live region.
Parsing CSV/TSV text
parseDelimitedText(text, { delimiter }) is a pure parsing utility — not a new component or a props-driven "preset" — that turns CSV/TSV (or any single-character-delimited) text into { columns, rows } for DataGrid's existing columns/rows props, unchanged.
- Delimiter is explicit.
delimiteris required and used exactly as given —parseDelimitedTextnever guesses a delimiter from the input. Pass','for CSV or'\t'for TSV. - Header row. The first parsed row is always the header row, supplying both
keyandheaderfor every derivedDataGridColumnDef. There is no positional/headerless mode. - Quoting. A quoted field (
"…") may contain the delimiter, embedded newlines (\nor\r\n), and an escaped double quote written as"". - Line endings. Both
\nand\r\nare accepted, including a mix of the two in the same input. - Blank lines are skipped. A line with nothing at all between two line endings — including the header line — is dropped rather than becoming an empty row/column. A line that has delimiters but empty values (e.g.
,,) is not blank and is kept. - Duplicate/empty headers. Header names are trimmed when deriving each column's
key. An empty header (after trimming) falls back tocolumn_<n>(1-indexed by position); akeythat collides with one already assigned gets_2,_3, … appended, deterministically by left-to-right header order. The displayedheaderkeeps the original text (even duplicated, or empty once a key was defaulted) — only the internalkeyis disambiguated. - Values are always strings.
parseDelimitedTextnever infers or coerces a numeric/date column — convert one yourself, either by mappingrowsbefore handing them to DataGrid or with that column'sgetValue. getRowId. Parsed rows have no id of their own. Either pick a column you know is unique in your data (getRowId={(row) => row.email}) or derive one from position, as in the example above.
Unsupported behavior and limitations
- Extremely tall/wide content is not clamped.
virtualizeRows/virtualizeColumnsrender actual-size row and column tracks and translate them into view — they never fake or clamp the grid's total scrollable height/width the way some virtualization libraries do at extreme row/column counts. Every browser has a hard ceiling on an element's rendered size, past which layout silently breaks (rows overlap, scroll position stops tracking, or the element is clipped to the ceiling): roughly 17,895,697px in Gecko (Firefox) and roughly 33,554,428px in Chromium and WebKit (Chrome, Edge, Safari). At the default 44px row height that's roughly 406,000 rows in Gecko and roughly 762,000 rows in Chromium/WebKit before row virtualization's total height hits the ceiling — proportionally fewer at a largerrowHeightor azoomabove 100%, since both scale the total height directly. This is a deliberate decision, not an oversight: DataGrid does not clamp, cap, or otherwise paper over the ceiling, because doing so would silently misrepresent the data's true size (a clamped scrollbar no longer means what it says) for a case only reachable with unrealistically large datasets. If your data can approach these row/column counts, page or window it server-side (or client-side ahead of DataGrid) so the grid itself never receives more rows/columns than a browser can lay out correctly. - Row/column virtualization requires a browser.
virtualizeRows/virtualizeColumnsneed realResizeObserver/scroll measurements and are inert during server rendering — seevirtualization-adapter.svelte.ts's SSR accessors, which report fixed aggregate sizes and empty virtual windows instead of touching the DOM. A server-rendered grid with virtualization enabled renders its full row/column set on the server and virtualizes only after hydration in the browser. - Search and zoom scan/scale the full dataset, not just what's rendered.
searchcomputes matches over every row and column regardless of virtualization (see Search), andzoomscales every row's height into the virtualizer's total-height calculation (see Zoom) — both cost is proportional to the fullrows/columnssize, not the virtualized window, on top of the row/column-count ceiling above.
When to use
- Rendering interactive tabular data that will need grid behavior such as selection, virtualization, resizing, or editing.
- You need role=grid semantics instead of native table semantics.
- You need inline cell editing backed by Cinder's own Input, with commit/cancel keyboard behavior handled for you.
- You need opt-in pointer- and keyboard-driven column resizing or reordering (`resizableColumns` / `reorderableColumns`).
- You need opt-in toolbar search/zoom controls, or `zoom` scaling row/header height, column width, and text.
- You only need a semantic read-only table — use DataTable or the Table family instead.
Examples
Pinned order grid
A static ARIA grid with stable row ids, explicit column widths, and a pinned identifier column.
Virtualized activity log
A fixed-height DataGrid window rendering 50,000 rows and a horizontal column window with pinned edges.
Props
Name | Type | Default | Description |
|---|---|---|---|
rows required | TRow[] | ||
columns required | DataGridColumnDef<TRow>[] | ||
getRowId required | (row: TRow) => string | Stable row identity used for ARIA ids and row-scoped state. | |
density | 'compact' | 'comfortable' | 'spacious' | 'comfortable' | Controls body row padding density. Defaults to 'comfortable'. |
stickyHeader | boolean | true | Keeps the column header row pinned to the top edge while scrolling. Defaults to true. |
virtualizeRows | boolean | false | Enables fixed-height row virtualization. |
virtualizeColumns | boolean | false | Enables LTR horizontal virtualization for unpinned columns. Pinned columns stay rendered. |
rowHeight | number | Fixed body-row pixel height used by row virtualization. Defaults to 44 when omitted or invalid. | |
resizableColumns | boolean | false | Enables a pointer- and keyboard-driven resize handle on resizable
header cells. Off by default so existing grids render unchanged. A
column opts out individually with resizable: false. |
reorderableColumns | boolean | false | Enables pointer drag and keyboard reordering of header cells. Off by
default so existing grids render unchanged. A column can only be
reordered among columns that share its pin value — reordering never
moves a column across the left-pinned, unpinned, or right-pinned
groups. |
columnOrder bindable | string[] | Applies a supplied column order. $bindable — DataGrid assigns it
directly after a pointer or keyboard reorder (see reorderableColumns),
in addition to calling onColumnOrderChange, mirroring sortModel. | |
onColumnOrderChange | (columnOrder: readonly string[]) => void | Called after the user reorders a column and DataGrid updates columnOrder. | |
columnSizing bindable | Record<string, number> | Overrides resolved column widths by column key. $bindable — DataGrid
assigns it directly after a pointer or keyboard resize (see
resizableColumns), in addition to calling onColumnSizingChange,
mirroring sortModel. | |
onColumnSizingChange | (columnSizing: DataGridColumnSizing) => void | Called after the user resizes a column and DataGrid updates columnSizing. | |
columnPinning | {
left?: readonly string[];
right?: readonly string[];
} | Pins supplied column keys to the left or right edge. | |
selectionMode | 'none' | 'single' | 'multiple' | 'none' | Controls row-selection behavior. Cell focus and range selection remain available. |
selectionModel bindable | string[] | Controlled row-selection ids, keyed by getRowId. | |
onSelectionModelChange | (selectionModel: DataGridSelectionModel) => void | Called when row selection changes through cell interaction. | |
sortModel bindable | DataGridSortModelItem[] | [] | Controls the row sort order used to render rows. |
onSortModelChange | (sortModel: DataGridSortModel) => void | Called after the user changes sort order and DataGrid updates sortModel. | |
onCellEdit | (row: TRow, columnKey: string, value: unknown) => void | Called after an in-grid cell edit commits (Enter, Tab, or blurring the
edit input) on an editable column. value is typed for the column's
resolved editType: a number column emits a number, or undefined
for an empty draft — never NaN — and leaves the original value
uncommitted for a draft that fails to parse as a number; every other
column emits the edited string. DataGrid never mutates the rows
prop — apply value to your own data from this callback. | |
rowClass | text | Additional class names for body rows. | |
getRowAriaLabel | (row: TRow, rowIndex: number) => stringundefined | Optional accessible row label for screen-reader row summaries. | |
search | boolean | false | Renders a toolbar above the grid (outside role="grid") with a search
field plus next/previous match controls. Matching is case-insensitive,
checks every column's resolved value (via getValue when supplied) as
a string, runs 300ms after the user stops typing, and covers every row
and column regardless of virtualization. Off by default: a grid that
doesn't opt in renders no toolbar, computes no matches, and starts no
timers. |
zoom bindable | number | 100 | Multiplies row height, header height, column width, and cell text size
by zoom / 100. $bindable — DataGrid assigns it directly after a
zoom-control interaction (see zoomControls), in addition to calling
onZoomChange, mirroring sortModel. Defaults to 100. Clamped to
[50, 200]; a non-finite value (e.g. NaN) is ignored and falls back
to 100 with the same dev-warn pattern rowHeight uses.
columnSizing always stays in unscaled base pixels regardless of
zoom — only the rendered, on-screen size is scaled — so a resize
(resizableColumns) keeps reporting the same base width whatever the
current zoom level is. |
onZoomChange | (zoom: number) => void | Called after a zoom-control interaction and DataGrid updates zoom. | |
zoomControls | boolean | false | Renders a toolbar group (in the same toolbar search uses) with the
current row count, the current visible column count, and zoom in/out
controls for zoom. Off by default: a grid that doesn't opt in renders
no extra toolbar DOM. Renders in the same <Toolbar> as search when
both are enabled; with neither enabled, DataGrid renders no toolbar at
all. |
id | text | ||
onkeydown | KeyboardEventHandler<T>undefinednull |