Responsive Table Mobile Layout: Rows to Cards in React
You ship an admin page with a seven-column table, open it on a phone, and see the client name, half a city and a horizontal scrollbar. The column headers have scrolled away by the time you reach the amount, and nobody can tell which number belongs to which row. A good responsive table mobile view is a different layout for most tables, not a narrower version of the same one. This tutorial shows when to turn rows into cards, which columns make it onto the card, how to build the switch on a plain shadcn/ui table without a hydration flash, and how sorting, selection and bulk actions keep working once the headers and the checkbox column are gone.
The short version of this rule is one of nine in Mobile-first React components. This post is the detailed version.
When should a table become cards on a phone?
When people read one record at a time. A list of clients, orders, invoices or tickets is a list of records: on a phone you scan for the one you want, then open it. A card per row shows everything that matters about that record in one glance, with no sideways scrolling and no header to remember.
Keep the table when the job is comparing values across rows and columns. A timesheet, a pricing matrix, a monthly revenue grid or a feature comparison only makes sense when the columns line up, and cards destroy that. For those, let the table scroll sideways inside its own box and pin the first column so every row keeps its name.
The last row catches a common mistake: a "table" with two columns, Label and Value, describing one client. That is a description list. The Description List stacks label above value on phones and switches to one to three columns based on its container width, so it fits a narrow sidebar card as well as a full page.
Why does horizontal scrolling fail for records?
A 390px screen shows two or three columns of an admin table at once. To read one record, people scroll right, lose the name column, scroll back, and repeat for every row. The header usually scrolls out of view vertically too, so the numbers they reach have no labels. Every row costs two scroll directions.
Horizontal scrolling is also what you get by default. Out of the box, shadcn table mobile responsive behavior means a scroll: the shadcn/ui Table wraps the <table> in a div with relative w-full overflow-x-auto, so a wide table never breaks the page layout, it just scrolls inside that box. That is the right fallback, and the right layout for comparison grids, but for a client list it means the phone gets the desktop table at a third of the width.
Which columns survive on a card?
Five or six of them, each in a defined slot. Go through your columns and give each one a role:
- Title: the field people recognize the record by. The client name, the order number, the ticket subject. One per card.
- Subtitle: the line that tells two similar titles apart, like the city or the email. Optional.
- Meta: one value in the top corner that people scan the list for. A status badge or an amount, not both.
- Fields: two to four label and value pairs under the title. Pick the ones people use to decide whether to open the record.
- Hidden: everything else. Internal ids, created-at dates, columns that only exist for sorting on desktop.
For a client list with Name, Tax ID, City, Status, Balance, Last invoice, Owner and Created, that gives: Name as the title, City as the subtitle, Status as the meta, Balance, Last invoice and Owner as fields, and Tax ID and Created hidden. The record is still one tap away for the hidden columns.
Actions move into a "⋯" menu in the card's corner that opens as a bottom sheet. Inline icon buttons in a row are fine with a mouse; on a card they compete with the title for a 44px touch target.
How do you turn a shadcn table into cards on mobile?
Render the table and a list of cards from the same data, and let CSS pick one. Here is the full component on top of the stock shadcn/ui table (npx shadcn@latest add table), with a plain span for the status so it needs nothing else:
That is the whole table to cards on mobile pattern. Three details matter more than the styling.
The switch is a CSS breakpoint. hidden md:block and md:hidden are both in the server HTML, so a phone paints the cards on first load. If you switch with a useIsMobile() hook instead, the server renders the table (the hook is false there), the phone shows it, and the cards replace it after hydration. The use mobile hook guide covers why that happens and where a hook is still the right tool.
The cards are a real list. <ul> with an aria-label tells a screen reader how many records there are, and <dt> / <dd> pairs keep each value attached to its label. Hiding the table with display: none also removes it from the accessibility tree, so screen reader users on a phone get the same cards as everyone else, not a hidden table.
Both copies render, so the DOM holds every row twice. For 25 rows a page that is nothing. For hundreds, paginate or virtualize, or render the hidden layout lazily. Past 200 rows, the Wingo UI Data Table virtualizes the visible layout and renders only a first screen of 30 rows in the hidden one.
What about the CSS-only data-label trick?
The popular alternative keeps one <table>, sets display: block on rows and cells below a breakpoint, and prints each header through td::before { content: attr(data-label) }. It saves the second copy of the markup, but it has two costs. Changing display on table elements used to strip their table semantics: Adrian Roselli's long-running test post records that Safari dropped them for every display value until Safari 17 fixed it, and as of his October 2023 update display: contents is the one value that may still cause issues (Tables, CSS Display Properties, and ARIA (opens in a new tab)). And a screen reader still announces a table with seven columns while the screen shows a stack of cards, with the labels living in CSS.
If you use it, test with VoiceOver on an iPhone and TalkBack on Android. If you cannot test, the two-layout version above is the safer default.
How do sorting and bulk actions work without headers?
They need their own controls, because the things people pressed on desktop are gone. Plan each one:
- Sort: a row of "Sort by" chips above the cards, one per sortable column, tap to cycle the direction. Or, with many sortable columns, a Sort button that opens a bottom sheet listing "Balance, descending", "Client, ascending" and so on.
- Filters: the same filter UI as desktop, opened in a bottom sheet instead of a popover.
- Selection: no checkbox column. Long press a card to start selecting (the way Gmail and Photos do it on Android), then each tap toggles a card, and a checkbox appears on every card while selection mode is on.
- Bulk actions: a bar that floats at the bottom of the screen while rows are selected, padded for the home indicator, instead of a toolbar band at the top that has scrolled away.
- Paging: Load more at the end of the list. Numbered pages with 32px page buttons are hard to hit and break the scroll flow.
Long press is invisible, so it cannot be the only way in. Keep the "⋯" menu visible on every card, and if selection matters, add a visible Select action to the toolbar or the row menu as well.
How do you get the same layout from Wingo UI?
Use the Table for small read-only tables and the Data Table for admin lists. Both make a React table mobile responsive without a second markup branch: they render the desktop table and the phone layout in the server HTML and switch at 768px with CSS, and both assign card roles from your columns: the first column is the title, the last numeric column (in the Data Table, the first badge column before that) is the meta, and the rest are fields until you say otherwise with mobile.
Here is an orders table with the roles spelled out:
On a phone each order is a card with the customer as the title, the city under it, the total in the corner, and Placed and Items as fields. The order number stays on desktop only. Columns past the fourth field move behind a "Show more" toggle on the card. Because customer, placed and total are sortable, a row of Sort by chips appears above the cards, and onRowClick makes each card a single button that keyboard users reach with Tab.
$ npx wingo-ui@latest add tableThe demo is an invoice with five lines and totals. Narrow your window, or open the page on a phone, to see the cards and the separate totals card. For a receipt-like look, mobileLayout="stack" lists label and value lines without card chrome, and mobileLayout="scroll" keeps the table scrolling sideways with the first column pinned, for comparison grids.
How does the Data Table handle selection on a phone?
The way the previous section describes. With selectionMode="multiple", a long press of about 450ms selects a card, then taps add or remove cards, and the bulk actions you return from bulkActions float at the bottom. The sort button in the toolbar opens a bottom sheet of "Balance, descending" style options, filters and the row menu open as bottom sheets too, and mobilePagination="load-more" (the default) appends rows.
Here the defaults do the rest: Client becomes the title because it is first, and Status becomes the meta because it is the first badge column. Balance and Owner are the fields. The Data Table shows at most four fields and drops the others from the card, so put the important ones first or mark the rest hidden on purpose.
$ npx wingo-ui@latest add data-tableWhen the column mapping is not enough, both components take a renderCard function for a card of your own, and mobileLayout="scroll" opts a single table out of cards.
What if the rows are simpler than a table?
Then skip the table on every screen size. A list of team members, notifications or settings with a name, a line of detail and one value is a list, and the List gives it rows with an avatar or icon, a trailing value, a row menu, and swipe actions on touch (swipe left to archive or delete, with a long press sheet as the visible alternative). It reads well at 390px and at 1440px without a breakpoint.
How do you test a responsive table on mobile?
Check the one thing that breaks most: the page should never scroll sideways at phone width. A Playwright test catches it in CI:
Add a 360px case for small Android phones, and run it in light and dark. Then do one pass on a real phone: long press a card, open the row menu and the sort control, and check that the bulk bar clears the home indicator.
Where should you start?
Take your busiest admin table, give each column a role on paper, and build the card layout next to the table with md:hidden. If you would rather start from components that already do the mapping, the sort chips, the long press selection and the floating bulk bar, the Table and the Data Table are part of Wingo UI Pro; add one with npx wingo-ui@latest add data-table. For the step by step build of filters and server pagination on TanStack, read the TanStack Table v9 data table tutorial, and find more phone layouts in Mobile UI in React.
FAQ
How do I make a table responsive on mobile?
Decide first whether people read records or compare columns. For records, render a card per row below 768px with the main field as the title and two to four labeled fields; for comparison grids, keep the table and let it scroll sideways inside its box with the first column pinned.
Is the shadcn table responsive on mobile?
It scrolls. The shadcn/ui Table wraps the table in a div with overflow-x-auto, so a wide table scrolls sideways inside its container. Turning rows into cards is up to you, usually with a second markup branch hidden and shown with md: classes.
Should I use display: block on table rows to make cards?
It works visually and keeps one copy of the markup, but changing display on table elements removed table semantics in Safari until Safari 17 (Adrian Roselli, October 2023), and display: contents can still cause problems. A separate list of cards with real dt and dd labels is easier to get right for screen readers.
How do users sort a table on a phone without column headers?
Give them a control that does what the headers did: a row of Sort by chips above the cards, or a Sort button that opens a bottom sheet listing each column ascending and descending.
Should I use useIsMobile to switch between the table and the cards?
No. A media query hook is false on the server, so phones render the table first and swap to cards after hydration. Render both and hide one with md:hidden and hidden md:block, which is correct in the first paint.
- Tables
- Mobile
- Responsive design
- React
- Tailwind CSS