Tables are notoriously difficult on mobile. Here are four patterns that solve this.
Pattern 1: Horizontal Scroll
The simplest approach — wrap the table in a scrollable container.
export function ScrollTable({ children }: { children: React.ReactNode }) {
return (
<div className="relative border rounded-lg">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">{children}</table>
</div>
{/* Scroll shadow indicators */}
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none md:hidden" />
</div>
);
}
Pattern 2: Card Layout on Mobile
"use client";
import { useMediaQuery } from "@/hooks/useMediaQuery";
interface Column<T> {
key: string;
label: string;
render: (row: T) => React.ReactNode;
primary?: boolean; // Show as card title
}
interface ResponsiveTableProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (row: T) => string;
}
export function ResponsiveTable<T>({
data,
columns,
keyExtractor,
}: ResponsiveTableProps<T>) {
const isMobile = useMediaQuery("(max-width: 768px)");
if (isMobile) {
return (
<div className="space-y-3">
{data.map((row) => {
const primaryCol = columns.find((c) => c.primary);
const otherCols = columns.filter((c) => !c.primary);
return (
<div key={keyExtractor(row)} className="border rounded-lg p-4">
{primaryCol && (
<div className="font-medium text-base mb-2">
{primaryCol.render(row)}
</div>
)}
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{otherCols.map((col) => (
<div key={col.key}>
<dt className="text-muted-foreground text-xs">{col.label}</dt>
<dd className="font-medium">{col.render(row)}</dd>
</div>
))}
</dl>
</div>
);
})}
</div>
);
}
return (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{columns.map((col) => (
<th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={keyExtractor(row)} className="border-b last:border-0 hover:bg-muted/30">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
Pattern 3: Collapsible Rows
"use client";
import { useState } from "react";
interface CollapsibleRow<T> {
data: T;
primaryColumns: { key: string; label: string; render: (row: T) => React.ReactNode }[];
secondaryColumns: { key: string; label: string; render: (row: T) => React.ReactNode }[];
}
export function CollapsibleTable<T>({
rows,
keyExtractor,
}: {
rows: CollapsibleRow<T>[];
keyExtractor: (row: T) => string;
}) {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="w-8 px-2" />
{rows[0]?.primaryColumns.map((col) => (
<th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const id = keyExtractor(row.data);
const isExpanded = expanded.has(id);
return (
<>
<tr
key={id}
className="border-b cursor-pointer hover:bg-muted/30"
onClick={() => toggle(id)}
>
<td className="px-2 text-center">
<span
className={`inline-block transition-transform ${isExpanded ? "rotate-90" : ""}`}
>
▶
</span>
</td>
{row.primaryColumns.map((col) => (
<td key={col.key} className="px-4 py-3">
{col.render(row.data)}
</td>
))}
</tr>
{isExpanded && (
<tr key={`${id}-details`} className="border-b bg-muted/10">
<td colSpan={row.primaryColumns.length + 1} className="px-6 py-3">
<dl className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
{row.secondaryColumns.map((col) => (
<div key={col.key}>
<dt className="text-muted-foreground text-xs">{col.label}</dt>
<dd className="font-medium mt-0.5">{col.render(row.data)}</dd>
</div>
))}
</dl>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
);
}
Pattern 4: Column Priority
"use client";
import { useMediaQuery } from "@/hooks/useMediaQuery";
interface PriorityColumn<T> {
key: string;
label: string;
render: (row: T) => React.ReactNode;
priority: 1 | 2 | 3; // 1 = always visible, 2 = tablet+, 3 = desktop only
}
export function PriorityTable<T>({
data,
columns,
keyExtractor,
}: {
data: T[];
columns: PriorityColumn<T>[];
keyExtractor: (row: T) => string;
}) {
const isTablet = useMediaQuery("(min-width: 768px)");
const isDesktop = useMediaQuery("(min-width: 1024px)");
const visibleColumns = columns.filter((col) => {
if (col.priority === 1) return true;
if (col.priority === 2) return isTablet;
if (col.priority === 3) return isDesktop;
return false;
});
return (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{visibleColumns.map((col) => (
<th key={col.key} className="px-4 py-3 text-left font-medium text-muted-foreground">
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={keyExtractor(row)} className="border-b last:border-0 hover:bg-muted/30">
{visibleColumns.map((col) => (
<td key={col.key} className="px-4 py-3">
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
useMediaQuery Hook
// hooks/useMediaQuery.ts
"use client";
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
Need Mobile-Optimized Interfaces?
We specialize in responsive design that works on every device. Get in touch to discuss your project.