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.
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.
Jobs
Jobs
Jobs
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
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.
Columns is data, not JSX. Both renderers consume the same array; adding a column changes both shells at once. Drift impossible.
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.
SSR-safe: initial state respects typeof window. The breakpoint is 720px because below that, 4 columns of meaningful text don't fit.
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.
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.
Both views tested every PR, no "works on my Mac" surprises.
Final composition
Columns, hook, two renderers, one entry point. ~90 lines.
| Job | Due | Amount | Status |
|---|---|---|---|
| Floor screed | Mon | $1,200 | Pending |
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 — noonClickon 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: nonedoubles the DOM and confuses screen readers.