Data Table
by Dax · tsx · 69 lines
A sortable, paginated data table following Material Design 3 patterns. Click any column header to sort. Clean and minimal with built-in pagination controls.
tabledatasortingpagination
69 lines
"use client";
import { useState, useMemo } from "react";
import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
type Column<T> = { key: keyof T; label: string; sortable?: boolean };
export function DataTable<T extends Record<string, any>>({
columns, data, pageSize = 10,
}: {
columns: Column<T>[]; data: T[]; pageSize?: number;
}) {
const [sort, setSort] = useState<{ key: keyof T; asc: boolean } | null>(null);
const [page, setPage] = useState(0);
const sorted = useMemo(() => {
if (!sort) return data;
return [...data].sort((a, b) => {
const v = a[sort.key] > b[sort.key] ? 1 : a[sort.key] < b[sort.key] ? -1 : 0;
return sort.asc ? v : -v;
});
}, [data, sort]);
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
const pages = Math.ceil(data.length / pageSize);
return (
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs font-medium text-neutral-500 uppercase tracking-wide">
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
onClick={() => col.sortable !== false && setSort(s =>
s?.key === col.key ? { key: col.key, asc: !s.asc } : { key: col.key, asc: true }
)}
className={`px-4 py-3 ${col.sortable !== false ? "cursor-pointer select-none hover:text-neutral-900" : ""}`}
>
<span className="inline-flex items-center gap-1">
{col.label}
{sort?.key === col.key && (sort.asc ? <ChevronUp size={14} /> : <ChevronDown size={14} />)}
</span>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{paged.map((row, i) => (
<tr key={i} className="hover:bg-neutral-50 transition-colors">
{columns.map((col) => (
<td key={String(col.key)} className="px-4 py-3 text-neutral-700">{String(row[col.key])}</td>
))}
</tr>
))}
</tbody>
</table>
{pages > 1 && (
<div className="flex items-center justify-between border-t border-neutral-100 px-4 py-2 text-xs text-neutral-500">
<span>{page * pageSize + 1}–{Math.min((page + 1) * pageSize, data.length)} of {data.length}</span>
<div className="flex gap-1">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0} className="p-1 rounded hover:bg-neutral-100 disabled:opacity-30"><ChevronLeft size={16} /></button>
<button onClick={() => setPage(p => Math.min(pages - 1, p + 1))} disabled={page >= pages - 1} className="p-1 rounded hover:bg-neutral-100 disabled:opacity-30"><ChevronRight size={16} /></button>
</div>
</div>
)}
</div>
);
}