Motivation
I've been thinking about this feature since 2018 when this library was created. Tables are a bad experience on small viewports. Right now the answer is horizontal scroll plus per-column hide breakpoints, which drops data instead of reflowing it. A built-in card view (each row rendered as a stacked label/value card below a breakpoint) solves this properly.
No major React table library I am aware of has this sort of feature:
- MUI X DataGrid declined it for the community tier (mui/mui-x#6460, 60+ reactions) and later shipped it as Pro-only "list view".
- AG Grid has open requests (ag-grid#3137, #8913) and recommends column hiding instead.
- TanStack Table is headless, so you build your own card renderer.
- The closest prior art is jQuery-era bootstrap-table's mobile extension (
mobile-responsive + columnsHidden).
Requirements
- Opt-in card layout, activated either always or below a configurable breakpoint.
- Default card renders label/value pairs derived from column
name + selector/cell, zero config beyond turning it on (css-tricks pattern).
- Per-column control: omit a column from cards, or hide just its label (e.g. an avatar or title field that speaks for itself).
- Custom card renderer for full control.
- Core features keep working in card mode: selection, expansion, pagination, conditional row styles, row events, row context menu.
- A sorting story, since column headers don't exist in card mode. This was MUI's main objection.
- Responsive activation must be SSR-safe.
Proposed API
// always cards
<DataTable columns={columns} data={data} cardView />
// cards at/below a breakpoint (same Media values as column `hide`)
<DataTable columns={columns} data={data} cardView="sm" />
// custom renderer
<DataTable
cardView="md"
cardComponent={({ row, fields, selected, toggleSelected, expanded, toggleExpanded }) => (
<MyCard ... />
)}
/>
New TableColumn fields:
{
name: 'Avatar',
cell: row => <Avatar src={row.avatar} />,
cardHide: true, // omit from card view entirely
// or
cardHideLabel: true, // render value only, no "Avatar:" label
}
Default card: one bordered block per row, each visible column as a label: value line. Selection checkbox and expander button render in the card's header strip. Themed via existing --rdt-* custom properties plus new rdt_card* class names.
Sorting and filtering in card mode
Sorting and filtering state is already headless (useSorting, useColumnFilter, the reducer). Header cells are just one UI binding to that state, so card mode adds a second binding: a card toolbar rendered above the cards, same pattern as every mobile list UI.
- Sort control: a field select listing
sortable columns (labeled by name) plus an asc/desc toggle. Only rendered when sorting is in play.
- Filters button: expands a collapsible section stacking each filterable column's existing filter input vertically, bound to the same per-column filter state as the header inputs. Not a bottom sheet or modal in v1. No portals, focus traps, or scroll locking in a table library.
- Active filter badge: when filters are applied and the section is collapsed, the button shows a count ("Filters · 2") so active filters stay visible.
The toolbar is swappable via cardToolbarComponent, following the existing precedent (paginationComponent, selectableRowsComponent, expandableRowsComponent). It receives the sort/filter slices as its prop contract. Apps with their own design system replace it, everyone else gets working defaults.
Tradeoff: a default toolbar means new localization keys (sort label, direction, filters, clear) and some baked-in design opinion. The headless-only alternative keeps the surface smaller but ships as "mobile support" that silently loses sorting, which is exactly the objection that stalled the MUI request.
Phase 2, non-blocking: tappable sort/filter chip row, multi-sort UI, bottom-sheet presentation.
Architecture
Follows the feature-slice pattern (ARCHITECTURE.md):
useCardView hook owns the feature: resolves the cardView prop, subscribes to matchMedia when a breakpoint is given, returns a memoized CardViewSlice (null when off). SSR renders table mode; the media query applies on mount (documented hydration note, same tradeoff as any matchMedia UI).
cardView slice on RowContext: carries { active, fields, cardComponent, hideLabelById }. Static config, identity-stable per the slice invariants. active flips only on breakpoint crossings.
TableCard component: TableRow stays the single row orchestrator (selection/expansion/events/conditional styles all live there) and branches on cardView?.active to render TableCard instead of the TableCol cell loop. Expander content renders inside/below the card.
CardToolbar component: rendered by DataTable above the body when card mode is active. Consumes the existing sorting/filtering slices, swappable via cardToolbarComponent.
- ARIA: card mode drops
role="table"/"row"/"cell" grid semantics for role="list"/"listitem", which matches the presentation.
- Feature compatibility:
- Works: selection, expansion, pagination, sorting (via toolbar), filtering, conditional row styles, row events, row context menu, progress/no-data states, theming.
- Inert in card mode (headers don't exist): column resize, drag reorder, pinning, column visibility menu, cell navigation, cell edit.
Semver: additive, opt-in, minor release. No changes to table-mode rendering or any existing public API.
Default UI
Mockup of the default rendering (screenshot below). What it pins down:
- Toolbar row above the cards: sort select ("Sort: Name") + direction arrow + Filters button. Filters expands inline under the toolbar, no modal.
- Filter count badge on the collapsed Filters button when filters are applied.
- Card = header strip (checkbox, primary field with no label, expander chevron) + body of label/value lines, one per row. Status-style columns keep their custom cell rendering (pills etc.) in the value slot.
- Selected card gets an accent border instead of the row background fill.
- Expanded content renders in a section at the bottom of the card, same
expandableRowsComponent contract.
Open questions
Motivation
I've been thinking about this feature since 2018 when this library was created. Tables are a bad experience on small viewports. Right now the answer is horizontal scroll plus per-column
hidebreakpoints, which drops data instead of reflowing it. A built-in card view (each row rendered as a stacked label/value card below a breakpoint) solves this properly.No major React table library I am aware of has this sort of feature:
mobile-responsive+columnsHidden).Requirements
name+selector/cell, zero config beyond turning it on (css-tricks pattern).Proposed API
New
TableColumnfields:Default card: one bordered block per row, each visible column as a
label: valueline. Selection checkbox and expander button render in the card's header strip. Themed via existing--rdt-*custom properties plus newrdt_card*class names.Sorting and filtering in card mode
Sorting and filtering state is already headless (
useSorting,useColumnFilter, the reducer). Header cells are just one UI binding to that state, so card mode adds a second binding: a card toolbar rendered above the cards, same pattern as every mobile list UI.sortablecolumns (labeled byname) plus an asc/desc toggle. Only rendered when sorting is in play.The toolbar is swappable via
cardToolbarComponent, following the existing precedent (paginationComponent,selectableRowsComponent,expandableRowsComponent). It receives the sort/filter slices as its prop contract. Apps with their own design system replace it, everyone else gets working defaults.Tradeoff: a default toolbar means new localization keys (sort label, direction, filters, clear) and some baked-in design opinion. The headless-only alternative keeps the surface smaller but ships as "mobile support" that silently loses sorting, which is exactly the objection that stalled the MUI request.
Phase 2, non-blocking: tappable sort/filter chip row, multi-sort UI, bottom-sheet presentation.
Architecture
Follows the feature-slice pattern (
ARCHITECTURE.md):useCardViewhook owns the feature: resolves thecardViewprop, subscribes tomatchMediawhen a breakpoint is given, returns a memoizedCardViewSlice(nullwhen off). SSR renders table mode; the media query applies on mount (documented hydration note, same tradeoff as anymatchMediaUI).cardViewslice onRowContext: carries{ active, fields, cardComponent, hideLabelById }. Static config, identity-stable per the slice invariants.activeflips only on breakpoint crossings.TableCardcomponent:TableRowstays the single row orchestrator (selection/expansion/events/conditional styles all live there) and branches oncardView?.activeto renderTableCardinstead of theTableColcell loop. Expander content renders inside/below the card.CardToolbarcomponent: rendered by DataTable above the body when card mode is active. Consumes the existing sorting/filtering slices, swappable viacardToolbarComponent.role="table"/"row"/"cell"grid semantics forrole="list"/"listitem", which matches the presentation.Semver: additive, opt-in, minor release. No changes to table-mode rendering or any existing public API.
Default UI
Mockup of the default rendering (screenshot below). What it pins down:
expandableRowsComponentcontract.Open questions
hidebreakpoints also apply inside cards, or are cards exempt (card mode already solves whathideworks around)?cardComponentandcardToolbarComponentprop contracts. These become public API, so they need to be right the first time.