Skip to content

RFC: Card view — responsive card layout for small viewports #1357

Description

@jbetancur

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

  1. Opt-in card layout, activated either always or below a configurable breakpoint.
  2. Default card renders label/value pairs derived from column name + selector/cell, zero config beyond turning it on (css-tricks pattern).
  3. Per-column control: omit a column from cards, or hide just its label (e.g. an avatar or title field that speaks for itself).
  4. Custom card renderer for full control.
  5. Core features keep working in card mode: selection, expansion, pagination, conditional row styles, row events, row context menu.
  6. A sorting story, since column headers don't exist in card mode. This was MUI's main objection.
  7. 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.
Image

Open questions

  • Should existing column hide breakpoints also apply inside cards, or are cards exempt (card mode already solves what hide works around)?
  • cardComponent and cardToolbarComponent prop contracts. These become public API, so they need to be right the first time.
  • Default card density: one label/value per line vs. a two-column grid on wider phones.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions