Data tables are difficult on small screens. The solution: show a full table on desktop and switch to a card layout on mobile. No horizontal scrolling required.
Step 1: Define the Table Data
type Invoice = {
id: string;
client: string;
amount: number;
status: "paid" | "pending" | "overdue";
date: string;
};
const invoices: Invoice[] = [
{ id: "INV-001", client: "Acme Corp", amount: 2500, status: "paid", date: "2026-01-15" },
{ id: "INV-002", client: "TechStart", amount: 4800, status: "pending", date: "2026-01-20" },
{ id: "INV-003", client: "GrowthCo", amount: 1200, status: "overdue", date: "2026-01-05" },
{ id: "INV-004", client: "DesignHub", amount: 3600, status: "paid", date: "2026-01-22" },
{ id: "INV-005", client: "DataFlow", amount: 7500, status: "pending", date: "2026-01-25" },
];
Step 2: Build the Desktop Table
function DesktopTable({ data, sortField, sortDirection, onSort }: {
data: Invoice[];
sortField: string;
sortDirection: "asc" | "desc";
onSort: (field: string) => void;
}) {
return (
<div className="hidden md:block">
<table className="w-full text-left">
<thead>
<tr className="border-b text-sm text-gray-500 dark:border-gray-700">
{["id", "client", "amount", "status", "date"].map((field) => (
<th key={field} className="px-4 py-3">
<button
onClick={() => onSort(field)}
className="flex items-center gap-1 font-medium hover:text-gray-900 dark:hover:text-white"
>
{field.charAt(0).toUpperCase() + field.slice(1)}
{sortField === field && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{data.map((invoice) => (
<tr
key={invoice.id}
className="border-b transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<td className="px-4 py-3 text-sm font-medium">{invoice.id}</td>
<td className="px-4 py-3 text-sm">{invoice.client}</td>
<td className="px-4 py-3 text-sm">
${invoice.amount.toLocaleString()}
</td>
<td className="px-4 py-3">
<StatusBadge status={invoice.status} />
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(invoice.date).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Step 3: Build the Mobile Card Layout
function MobileCards({ data }: { data: Invoice[] }) {
return (
<div className="space-y-4 md:hidden">
{data.map((invoice) => (
<div
key={invoice.id}
className="rounded-lg border p-4 dark:border-gray-700"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{invoice.id}</span>
<StatusBadge status={invoice.status} />
</div>
<p className="mt-2 font-semibold">{invoice.client}</p>
<div className="mt-3 flex items-center justify-between text-sm text-gray-500">
<span>${invoice.amount.toLocaleString()}</span>
<span>{new Date(invoice.date).toLocaleDateString()}</span>
</div>
</div>
))}
</div>
);
}
Step 4: Status Badge Component
function StatusBadge({ status }: { status: Invoice["status"] }) {
const styles = {
paid: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
pending: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
overdue: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
};
return (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
}
Step 5: Add Sorting and Filtering
"use client";
import { useState, useMemo } from "react";
export function DataTable() {
const [sortField, setSortField] = useState("date");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [filter, setFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
function handleSort(field: string) {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
}
const filteredData = useMemo(() => {
let data = [...invoices];
// Text filter
if (filter) {
const query = filter.toLowerCase();
data = data.filter(
(inv) =>
inv.id.toLowerCase().includes(query) ||
inv.client.toLowerCase().includes(query)
);
}
// Status filter
if (statusFilter !== "all") {
data = data.filter((inv) => inv.status === statusFilter);
}
// Sort
data.sort((a, b) => {
const aVal = a[sortField as keyof Invoice];
const bVal = b[sortField as keyof Invoice];
const modifier = sortDirection === "asc" ? 1 : -1;
if (typeof aVal === "number" && typeof bVal === "number") {
return (aVal - bVal) * modifier;
}
return String(aVal).localeCompare(String(bVal)) * modifier;
});
return data;
}, [filter, statusFilter, sortField, sortDirection]);
return (
<div>
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<input
type="text"
placeholder="Search invoices..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm sm:w-64 dark:border-gray-700 dark:bg-gray-800"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-lg border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<option value="all">All statuses</option>
<option value="paid">Paid</option>
<option value="pending">Pending</option>
<option value="overdue">Overdue</option>
</select>
</div>
{/* Table / Cards */}
<div className="mt-4">
<DesktopTable
data={filteredData}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
/>
<MobileCards data={filteredData} />
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<p className="py-8 text-center text-sm text-gray-500">
No invoices found.
</p>
)}
</div>
);
}
Alternative: Horizontal Scroll
For simpler cases, a scrollable table is acceptable:
<div className="overflow-x-auto">
<table className="min-w-[600px] w-full">
{/* table content */}
</table>
</div>
This works but is less user-friendly on mobile than the card approach.
Need Custom Dashboard Components?
We build data-rich dashboards and admin interfaces with responsive tables, charts, and real-time data. Contact us for a consultation.