DataTable
A data table rendered from column and row data, with sorting, row selection, pagination, and the loading and empty states built in.
Columns are plain header labels or descriptors ({ label, numeric, width, sortable, ... }) for per-column alignment, fixed widths, and sorting; compose a toolbar above when the screen needs one. Density tweaks affect padding live. Pass onRowEdit / onRowDelete for a trailing actions column: the pencil opens the row's string cells as fields with Save/Cancel (committing via onRowCommit), and Delete asks for a confirming second press before it fires. inlineEdit skips the pencil and lets a string cell be pressed straight into a field, committing via onCellCommit. onRowPress makes the whole row act on press and puts the keyboard and screen-reader path on a button in the row's first column, so keep your own controls out of that column when you use it. Data stays yours either way: the table reports intent and re-renders whatever rows it is handed back.
Treat rows and columns as immutable inputs. When editing data, replace the outer rows array and each changed row, as the examples below do. When changing a column, replace its descriptor and the columns array. Keep unchanged arrays and callbacks stable for large tables: Canvas caches sorting, paging, and selection summaries so typing a draft or opening a row action does not process the whole data set again. sortValue and rowKey must be pure functions of their inputs. If either closes over changing external data, supply a new callback identity; for sortValue, also replace its column descriptor and the columns array. A saved edit appears in sorted order when the updated rows arrive.
On narrow web and Android layouts, the columns still share the table width and wrap their text, each keeping a 110 px minimum (a column with a fixed width keeps that width), and they pan horizontally only when those minimums exceed the table width. A long cell wraps inside its column rather than widening the table. The overflowing scrollport is a keyboard tab stop, so the platform's arrow keys can reveal the remaining columns, and while it has keyboard focus the table's surface draws the theme's focus ring. A table that fits adds no scrolling tab stop, and on Android it takes no sideways drag either, so a tap that drifts sideways still presses its row and a swipe that starts across it scrolls the page (while TalkBack explores by touch the scroller stays enabled, so the exploring finger reaches every cell). On an iPhone-width table iOS shows only the first column instead, as SwiftUI Table does. A table that passes stacks does neither: at that width each row stacks its cells (see Stacked below). On the web and Android, and for a table that stacks on any platform, crossing that width changes the layout only: rows, custom cells, an open editor with its text and focus, and a windowed body's scroll position stay as they are when a window is resized across it or a phone measures the table on its first frame. The iOS first-column layout is the exception: below that width the columns after the first leave the table, with any editor or custom cell in them, and mount again when it widens.
A virtualized table's body scrolls its rows under the fixed header. On the web, while those rows overflow, the body is a keyboard tab stop of its own (after the column scroller when a narrow table pans, and before the rows' controls when they have any), the keyboard can scroll it, and while it has keyboard focus the table's surface draws the same focus ring. The body is the table's row group, so the rows it renders stay inside the table for a screen reader rather than behind an unnamed focusable node.
iOS
Android
Web
tsx
Default
Bordered
Attached
Striped
Compact
Comfortable
Loading
Empty
Sortable
Selectable
Paginated
Numeric and custom columns
Sparkline trend column
Avatar identity cells
Row actions
Inline editing
Stacked
Props
Do & Don’t
default
Don’t
Wiring search but dropping the footer leaves the user with no result count or way to page through 142 rows.
Do
Use the built-in paginated footer so the search result is always anchored to the total.
bulk
Don’t
Surfacing only the destructive Delete on a selection invites accidental data loss with no safer path.
Do
Pair the selection with a bulk bar that leads with the non-destructive action and keeps Delete visually distinct.
filter
Don’t
A filter control with no result count leaves the user guessing whether the filter narrowed anything.
Do
Status:
142 results
Echo the live result count next to the filter so its effect is visible.
empty
Don’t
Hiding the body entirely on no results collapses the table and looks broken.
Do
Keep the header and let the built-in emptyMessage span a centered row so the structure stays intact.
loading
Don’t
A bare "Loading…" string gives no sense of progress and reads like static content.
Do
Use the built-in loading skeletons so the load reads as the table taking shape in place.