Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Mobile Cards Table

Switch the Mobile/Desktop tab below — each row becomes its own card with labeled fields once the container narrows past 768px, no horizontal scrolling required. It's a container query, not a viewport breakpoint, so it works wherever the table is embedded — no separate mobile component, no JS media-query hook.

npx shadcn add https://shad-table.dev/r/mobile-cards-table.json
#1042Ava ThompsonAug 12, 2026Paid$284.00
#1041Liam ChenAug 11, 2026Pending$96.50
#1040Sofia PatelAug 11, 2026Paid$412.20
#1039Noah GarciaAug 10, 2026Refunded$58.00
#1038Mia JohnsonAug 09, 2026Paid$129.99
#1037Ethan KimAug 08, 2026Pending$74.30

How it works

1.Every table element gets a display override, not a second layout

There's no separate mobile component and no JS media-query hook — the same <thead>/<tbody>/<tr>/<td> markup renders both views. Below the breakpoint every element is forced to block/flex; above it, the variant puts it back to its native table display (table, table-row-group, table-row, table-cell).

src/components/mobile-cards/data-table.tsx
<Table className="block @[48rem]:table">
<TableHeader className="hidden @[48rem]:table-header-group">
<TableBody className="block ... @[48rem]:table-row-group">
<TableRow className="block ... @[48rem]:table-row">
<TableCell className="flex ... @[48rem]:table-cell">

2.It's a container query, not a viewport breakpoint

@[48rem] is Tailwind v4's container-query variant, keyed to the nearest ancestor with @container — here, the table's own wrapper div — instead of the browser viewport. A table embedded in a narrow sidebar or split-pane gets the card layout even on a wide desktop screen, and a table given more room stays a table even on a small one.

src/components/mobile-cards/data-table.tsx
<div className="@container rounded-md border">
<Table className="block @[48rem]:table">
{/* @[48rem] reacts to this div's width, not window width */}
</Table>
</div>

3.The header disappears; its text moves onto the cell as a data attribute

hidden @[48rem]:table-header-group removes the header row visually below the breakpoint (screen readers still get context from data-label). Each TableCell carries its own column header as data-label — that's the one piece of information a headerless card still needs per field.

src/components/mobile-cards/data-table.tsx
const header = cell.column.columnDef.header
const label = typeof header === 'string' ? header : undefined
<TableCell data-label={label}>

4.A pseudo-element renders the label — and disappears above the breakpoint

before:content-[attr(data-label)] pulls that data-label straight into CSS generated content, so the label renders without any extra DOM node. @[48rem]:before:content-none removes it again once the real <thead> is doing that job instead.

src/components/mobile-cards/data-table.tsx
className={cn(
'flex items-center justify-between gap-4 py-1 @[48rem]:table-cell @[48rem]:py-3',
'before:content-[attr(data-label)] before:text-xs before:text-muted-foreground',
'@[48rem]:before:content-none',
)}

5.The first column is styled as the card title, not another label:value row

cellIndex === 0 renders the first cell (the order id) without a data-label prefix and in a slightly heavier weight, so each card reads top-to-bottom the way a real mobile UI would: an identifying line first, then attribute rows underneath — instead of every field looking identical.

src/components/mobile-cards/data-table.tsx
const isPrimary = cellIndex === 0
className={isPrimary
? 'mb-1 text-sm font-medium @[48rem]:mb-0 @[48rem]:font-normal'
: 'before:content-[attr(data-label)] ...'}

6.A Mobile/Desktop tab forces the container width, not the browser

Since the transform keys off @container width, testing it doesn't require resizing the whole window. Mobile pins the demo to a fixed 375px box; Desktop sets a 900px min-width — a min-width, not just 100%, because the docs sidebar can leave the preview pane narrower than the table's own 768px breakpoint. Forcing the simulated width (with horizontal scroll if the pane is tighter) is what a device-preview tab is supposed to do.

src/components/mobile-cards/viewport-toggle.tsx
<Tabs value={view} onValueChange={(v) => setView(v as View)}>
<TabsList>
<TabsTrigger value="mobile">Mobile</TabsTrigger>
<TabsTrigger value="desktop">Desktop</TabsTrigger>
</TabsList>
</Tabs>
<div className="overflow-x-auto">
<div
style={
view === 'mobile'
? { width: 375, maxWidth: '100%' }
: { minWidth: 900 }
}
>
{children}
</div>
</div>