Home/ Cookbook/ Tables/ Responsive to cards

Build a table that becomes cards on phone — same data, two shells

By the end of this recipe you'll have one React component that renders a real <table> on desktop and a card list on phone — same row data, same handlers, two presentations.

Tables 2 primitives · 1 block · 0 patterns ~20 min React · @corelithzw/react

Overview

One row data type, one row component, two renderers. Above 720px you get a real semantic table with sortable headers. Below 720px the same rows render as cards with key/value pairs stacked vertically.

The Field worker checking jobs on their phone and the back-office clerk checking the same jobs on a desktop are doing different jobs. The phone user can't read a 7-column table on a 380px screen; the desktop user shouldn't be punished with a card list when they have 1400px of real estate. Both should see "the same data" in shapes that fit.

Don't horizontally scroll tables on phone. A horizontally-scrolling table is a sign you've given up. The user can only ever see 1 column; they have to drag back and forth to compare. Convert to cards — the row is still one logical thing, just shown vertically.

Skip this recipe if your table is read-only and short (a flat card list works on both). Skip if you have heavy editing/bulk actions — phones aren't the right surface for power-table work; ship a leaner mobile view instead.

What you'll build

Desktop table, phone cards, and the breakpoint switch.

Huchu · 1400px

Jobs

JobDue$St
Floor screedMon$1.2kP
Roof patchWed$480O
Plumbing fixFri$220O
Tile deliverySat$3.4kP
01 ≥720px — semantic <table>
Huchu · 380px

Jobs

Floor screed
DueMon
Amount$1,200
StatusPending
Roof patch
DueWed
Amount$480
StatusOpen
02 <720px — card list
Huchu · 720px

Jobs

↔ 720px breakpoint
JobDue$St
Floor screedMon$1.2kP
Roof patchWed$480O
03 Switch happens at 720px exactly

Required pieces

Just a row component and a breakpoint hook. CSS does the rest.

@corelithzw/react exports used here: RowCard, Button, Badge. The breakpoint hook is 8 lines.

Step-by-step build

01

Column metadata — one source for both renderers

Define columns once: key, label, format function, hidden-on-mobile flag. Both the table renderer and the card renderer read the same definition. Drift between them is the cardinal sin of this recipe.

Code-only step

Columns is data, not JSX. Both renderers consume the same array; adding a column changes both shells at once. Drift impossible.

Step 1 · COLS@corelithzw/react
02

Breakpoint hook — matchMedia, not window.innerWidth

matchMedia with a listener is the cheapest reliable way to read a breakpoint in React. Don't use window.innerWidth with a resize listener — it fires hundreds of times per drag and SSR will explode.

Code-only step

SSR-safe: initial state respects typeof window. The breakpoint is 720px because below that, 4 columns of meaningful text don't fit.

Step 2 · useMatchMedia
03

Two renderers, same row data, same handlers

The component returns either a <table> or a list of cards. Both read rows and onOpen from props — no parallel state. The shell switches; the data flow stays.

Floor screed
DueMon
Amount$1,200
StatusPending
Step 3 · Two renderers
04

Test both shells in dev — toggle in Storybook or a query string

The cardinal failure mode: developer never opens phone width, ships an "either" component that's broken on phone. Add a dev-only override (URL param, env var) that forces card mode regardless of viewport, and use it.

?shell=mobile · ?shell=desktop
Both views tested every PR, no "works on my Mac" surprises.
Step 4 · Dev override

Final composition

Columns, hook, two renderers, one entry point. ~90 lines.

Desktop ≥720px
JobDueAmountStatus
Floor screedMon$1,200Pending
Phone <720px
Floor screed
DueMon
Amount$1,200
StatusPending
JobList.tsx@corelithzw/react

Variations

Three forks for "which shell wins where".

Single shell only — drop the switch

If your app is mobile-only or desktop-only, render one and delete the other. Don't ship dead code "for when we expand" — when you expand, you'll rewrite anyway.

// mobile-only
export const JobList = JobCards;
// desktop-only
export const JobList = JobTable;

Hide columns instead of switching shells

For tables with 3–4 columns where the layout still works at phone width, hide the 1–2 nice-to-haves under display: none at narrow widths. No card transformation needed.

<th className="hide-mobile">
  Created by
</th>
// CSS
@media (max-width: 720px) {
  .hide-mobile { display: none; }
}

Container query, not media query

If the table lives inside a layout slot that may itself be narrow, switch on container width. Catches dashboards where two side-by-side tables each get half the viewport.

const ref = useRef();
const isWide = useContainerWidth(ref, 600);
return (
  <div ref={ref}>
    {isWide ? <Table /> : <Cards />}
  </div>
);

Accessibility

Two shells means two a11y surfaces — both must work.

  • Desktop uses a real <table aria-label>. Screen readers navigate by row/column. The label tells them which table they're in.
  • Phone uses <ul role="list"> with <li> children. Aria-label on the list mirrors the desktop table label.
  • Card content uses <dl> <dt> <dd>. A description list is the right semantic for label/value pairs — screen readers read "Due: Mon, Amount: $1,200" naturally.
  • Cards are real <button>s. Whole-card tap, keyboard focus, Enter/Space — no onClick on a <div>.
  • Each shell has the same focus behaviour. If desktop rows are focusable, phone cards must be too — switching shells must not change keyboard reachability.
  • Don't render both shells at once. Render only the matching shell. Rendering both with display: none doubles the DOM and confuses screen readers.