Responsive React table — mobile card layout
▶ See it before you install: the live mobile demo — the same table flipped between desktop rows and phone cards with one toggle.
Most React data tables answer the phone problem with a horizontal scrollbar.
AdaptTable answers it with a different layout: below the mobile breakpoint,
every row renders as a card — same columns, same filters, same search,
same selection, same URL state — and the switch happens by itself. There is
no second component to build, no isMobile plumbing, and nothing to
configure to get the default behavior.
<DataTable data={rows} columns={columns} rowKey={(r) => r.id} />That is already responsive. On a desktop viewport it renders the full table; on a phone it renders the card list. Everything below is tuning.
What changes on mobile — and what deliberately doesn’t
Section titled “What changes on mobile — and what deliberately doesn’t”| Surface | Desktop | Mobile |
|---|---|---|
| Rows | <table> rows |
Cards — one per row, labels from the column headers |
| Sorting | Clickable column headers | Native sort-by select and ascending/descending button |
| Pagination | Pager (or infinite) | paginationMode="auto" resolves to infinite scroll |
| Row actions | Trailing icon buttons | Card buttons |
| Long lists | Row virtualization (virtualize()) |
Card virtualization through the same feature |
| Filters, chips, search, selection, bulk bar, saved views, URL state | identical | identical |
The second half of that table is the point: behavior that took real work to get right — declarative filters, select-across-pages, shareable URL state — does not fork into a second code path on phones. One table, one state, two layouts.
Tuning the cards
Section titled “Tuning the cards”The built-in body lays fields out in a grid that responds to the card’s own
width. Labels sit above their values; unlabeled fields span the full width.
This keeps an identity block together while short details sit side by side.
Narrow cards fall back to one field per line, and long values wrap. Each kit
supplies its own card, typography, controls and theme colors, including dark
mode. Custom renderCard bodies retain their own layout.
Use a column renderer to show meaningful kit elements: an Avatar beside a
person’s name, a Badge for status, or a Progress for workload. The table preserves
those renderers in cards; it does not infer a status, avatar or action from a
column’s name. Use mobileLabel: "" for a self-explanatory identity block, and
keep labels on measurements and other values that need context.
mobileLabel(per column) — the label a card shows for that field; falls back to the column’s stringheader, then to its key. Set it to""for a field with no label at all — a bare avatar or title line — rather than an empty caption taking a line.resolveMobileLabelfrom@adapttable/react/adapteris the resolver every adapter uses, for a custom card layout that should match.hideOnMobile(per column) — drop a column from cards entirely.hideOnDesktop(per column) — show a field on cards only.mobileIdentityColumns— deprecated, accepted and ignored, removed in v4.hideOnMobiledecides what a card shows: every column without it appears.sortByOptions— the options offered by the mobile sort-by select. The direction button is disabled until a column is selected. It keeps the selected column while switching ascending/descending order, and uses the same URL and Saved Views state as desktop sorting. Its accessible name describes the next action; its text shows the current direction. All React, Angular and Vue kits use their native controls and localized sort labels.forceMobile— pin either layout regardless of viewport: cards inside a desktop dashboard panel, or the full table in a tablet kiosk. The mobile demo uses exactly this prop for its toggle.rowAppearance({ rowClassName })applies to desktop rows and mobile cards alike, and the class-hook /data-adapttable-partsurface names the card regions (cardDetail,group-card,summaryCard) for styling.rowAppearance({ rowStyle, rowHeight })applies the same way — see row styling and heights.
Keyboard scrolling inside a fixed-height list
Section titled “Keyboard scrolling inside a fixed-height list”Set maxHeight to keep the cards inside a bounded scroll region. The region
uses the table’s accessible name and is a Tab stop, so keyboard readers can
focus it and use native scrolling keys such as Page Down and Home. Mouse and
touch scrolling remain native. The inner card list keeps its list semantics,
row positions, styling parts and custom card content.
Omit maxHeight to use page scrolling; the region adds no Tab stop. Changing
only the height bound keeps the wrapper and list; virtualization still mounts
and removes off-screen cards as needed. In the mobile demo, use Bounded list
to try the two layouts.
Your own card
Section titled “Your own card”The built-in card groups labeled fields beneath any unlabeled, full-width
content. For a different hierarchy, renderCard can put an order’s total above
its reference or combine several columns into one identity area.
renderCard replaces the field body:
<DataTable data={people} columns={columns} rowKey={(r) => r.id} renderCard={(row, card) => ( <article className="person-card"> <img src={row.avatarUrl} alt="" /> <h3>{row.name}</h3> <dl> {card.fields.map(({ column, label, value }) => ( <div key={column.key}> {label && <dt>{label}</dt>} <dd>{value}</dd> </div> ))} </dl> </article> )}/>The card’s shell stays around what you return: the list-item semantics, the selection checkbox, the expand and tree toggles, the reorder controls, the row actions and the detail panel. A custom card cannot drop the parts that make the list usable, because it never owns them.
With rowReorder(...) from @adapttable/mantine/row-reorder composed, flat
cards expose up/down controls with a touch target of at least 44×44 px in
every kit (measured on the production build in
scripts/bench-runs/2026-09-23-2.json). Grouped and tree cards also expose the
kit-native Move to group… / Move under… menu, including explicit
confirmation with rowReorder(onRowReorder, { movePolicy: "confirm" });
touch never has to emulate a drag.
card.fields is what the built-in would have laid out — each field’s
column, its resolved label (undefined when the column asked for none)
and its value, rendered exactly as the built-in renders it, cell renderers
and editors included. So this is a layout decision, not a re-implementation:
reuse the values and arrange them your way. card.selected, card.expanded
and card.index come along for cards that change with their state.
Omit it to use the built-in card body.
Where the switch happens
Section titled “Where the switch happens”mobileBreakpoint is the width, in pixels, at or below which the cards take
over. It defaults to 768 — a phone in portrait.
<DataTable data={rows} columns={columns} rowKey={(r) => r.id} mobileBreakpoint={1024}/>Raise it when the table lives in a sidebar or a split pane: the viewport says “desktop” while the table itself has a phone’s width to work with, and the default would keep a five-column table in a 300px column. Lower it when the table is the whole page and its columns are narrow enough to survive.
The width in between
Section titled “The width in between”Between “everything fits” and “narrow enough for cards” there is a long middle where a table has too many columns. The usual outcomes are a horizontal scrollbar nobody finds, or columns squeezed until nothing is legible — neither is a decision.
responsivePriority is the decision, made by the person who knows the data:
const columns = [ { key: "name", header: "Name", width: 200 }, { key: "team", header: "Team", width: 160, responsivePriority: 1 }, { key: "note", header: "Note", width: 240, responsivePriority: 2 },];Priority 1 is kept longest, in the ordinary sense of the word. As the table
narrows, note goes first, then team. name never goes — a column that
omits responsivePriority is never dropped, which is how the columns carrying
the row’s identity stay put by saying nothing. A table where no column sets it
behaves exactly as it did before.
The budget is arithmetic on each column’s declared width (a resize wins over
it, and a column with no width is budgeted at 150px), so it settles in one pass
and gives the same answer every time. There is no measure-drop-remeasure loop,
which is what makes tables that do it flicker.
A dropped column is a fact about the viewport, not a choice the user made: it never reaches the layout state, the URL or a saved view, and the column menu still lists it.
It composes with everything else
Section titled “It composes with everything else”- Grouping renders group header blocks between cards, with the same collapse behavior as desktop — see row grouping.
- Virtualization windows the card list the same way it windows rows:
one
virtualize()feature, measured in a real browser across every adapter — see virtualization. - RTL flips the cards along with everything else — see i18n & RTL.
- Every adapter ships it: Mantine, MUI, Chakra, Ant Design, Radix, Base UI, shadcn/ui and unstyled all render the card layout natively — the comparison table tracks it as a built-in across the board.
When you still want the table on phones
Section titled “When you still want the table on phones”Set forceMobile={false} and the desktop layout renders everywhere —
sticky header, pinned columns and horizontal scrolling included. The cards
are the default because thumb-reach beats pinch-zoom for row-by-row work,
but the choice stays yours per table.
// Automatic cards on phones: omit forceMobile.<DataTable data={rows} columns={columns} rowKey={(row) => row.id} />
// Keep the table on phones; wide columns scroll within the table.<DataTable data={rows} columns={columns} rowKey={(row) => row.id} forceMobile={false} paginationMode="paged"/>true always renders cards; false always renders the table; omission follows
the viewport. These choices also affect paginationMode="auto". Set
paginationMode explicitly when loading behavior must stay the same across
layouts. A horizontally scrolling table is useful for comparing columns across
rows; cards are useful for reading and acting on one record at a time.
Related: Getting started · API reference · Live mobile demo
Editing across layouts
Section titled “Editing across layouts”Custom framework bindings can call preserveEditorFocusOnLayoutChange() from
@adapttable/core/binding immediately before publishing a responsive layout
change. It captures the active editor and caret, then restores them after the
DOM replacement. It leaves focus alone if the original control remains mounted,
the editor inventory changes, or focus moves outside the table. The supplied
React, Angular and Vue bindings already wire this behavior.
A layout change keeps row drafts, selection, filters and expanded rows in the same table state. Native editor focus and text selection follow viewport changes when the editor inventory remains unchanged. An explicit layout chooser keeps focus on the chooser. Keep stable row keys and render the supplied prepared field values in custom cards so native editors remain connected.
Mobile Save/Cancel and hierarchy actions have larger touch targets while desktop controls keep their kit sizing. The mobile showcase also exercises bounded scrolling and pagination choices.