
Free sortable React table in Tailwind with no library: click headers to sort, aria-sort on the active column, a caption, row headers and a plain HTML version.
components/we-inc/sortable-react-table-with-aria-sort.tsx
import { useMemo, useState } from "react";
type Order = { id: string; customer: string; city: string; date: string; total: number; status: "Paid" | "Pending" | "Refunded" };
const orders: Order[] = [
{ id: "#4821", customer: "Hana Osei", city: "Leeds", date: "2026-10-24", total: 184.5, status: "Paid" },
{ id: "#4820", customer: "Mateo Ruiz", city: "Bristol", date: "2026-10-23", total: 62, status: "Pending" },
{ id: "#4819", customer: "Clara Vos", city: "York", date: "2026-10-23", total: 311.2, status: "Paid" },
{ id: "#4818", customer: "Idris Bello", city: "Cardiff", date: "2026-10-21", total: 47.9, status: "Refunded" },
{ id: "#4817", customer: "Nell Park", city: "Glasgow", date: "2026-10-20", total: 129, status: "Paid" },
{ id: "#4816", customer: "Arjun Mehta", city: "Brighton", date: "2026-10-18", total: 238.75, status: "Pending" },
];
const columns: { key: keyof Order; label: string; numeric?: boolean }[] = [
{ key: "id", label: "Order" },
{ key: "customer", label: "Customer" },
{ key: "city", label: "City" },
{ key: "date", label: "Date" },
{ key: "total", label: "Total", numeric: true },
{ key: "status", label: "Status" },
];
const badge = { Paid: "bg-emerald-50 text-emerald-800 ring-emerald-600/20", Pending: "bg-amber-50 text-amber-800 ring-amber-600/20", Refunded: "bg-slate-100 text-slate-700 ring-slate-500/20" };
const money = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
const day = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" });
export default function SortableReactTableWithAriaSort() {
const [sort, setSort] = useState<{ key: keyof Order; dir: "ascending" | "descending" }>({ key: "date", dir: "descending" });
const rows = useMemo(() => {
const s = [...orders].sort((a, b) => (a[sort.key] < b[sort.key] ? -1 : a[sort.key] > b[sort.key] ? 1 : 0));
return sort.dir === "ascending" ? s : s.reverse();
}, [sort]);
const by = (key: keyof Order) =>
setSort((s) => ({ key, dir: s.key === key && s.dir === "ascending" ? "descending" : "ascending" }));
return (
<section className="bg-slate-50 px-5 py-14 sm:py-20">
<div className="mx-auto max-w-4xl">
<div className="overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm">
<table className="w-full min-w-[640px] border-collapse text-left text-sm">
<caption className="px-5 pb-3 pt-5 text-left">
<span className="block text-lg font-semibold text-slate-900">Recent orders</span>
<span className="block text-sm font-normal text-slate-600">Wren & Thistle Candles. Click a column heading to sort.</span>
</caption>
<thead>
<tr className="border-y border-slate-200 bg-slate-50">
{columns.map((c) => {
const active = sort.key === c.key;
return (
<th key={c.key} scope="col" aria-sort={active ? sort.dir : undefined} className={`px-2 py-1 font-medium text-slate-600 ${c.numeric ? "text-right" : ""}`}>
<button
type="button"
onClick={() => by(c.key)}
className={`inline-flex h-10 items-center gap-1.5 rounded-md px-3 hover:bg-slate-200/60 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-600 ${active ? "text-slate-900" : ""} ${c.numeric ? "flex-row-reverse" : ""}`}
>
{c.label}
<svg viewBox="0 0 12 12" className={`h-3 w-3 ${active ? "text-indigo-600" : "text-slate-400"}`} aria-hidden="true">
<path d="M6 1.5 9 5H3z" className={active && sort.dir === "descending" ? "opacity-25" : ""} fill="currentColor" />
<path d="M6 10.5 3 7h6z" className={active && sort.dir === "ascending" ? "opacity-25" : ""} fill="currentColor" />
</svg>
</button>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((o) => (
<tr key={o.id} className="hover:bg-slate-50">
<th scope="row" className="px-5 py-3.5 font-medium text-slate-900">{o.id}</th>
<td className="px-5 py-3.5 text-slate-700">{o.customer}</td>
<td className="px-5 py-3.5 text-slate-600">{o.city}</td>
<td className="px-5 py-3.5 text-slate-600"><time dateTime={o.date}>{day.format(new Date(o.date))}</time></td>
<td className="px-5 py-3.5 text-right font-medium text-slate-900 tabular-nums">{money.format(o.total)}</td>
<td className="px-5 py-3.5"><span className={`rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${badge[o.status]}`}>{o.status}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
);
}