# Sortable React Table With aria-sort

> 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.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/sortable-react-table-with-aria-sort).

- Page, with a live preview: https://shadcn.exchange/r/weinc/sortable-react-table-with-aria-sort
- Screenshot: https://shadcn.exchange/shot/weinc/sortable-react-table-with-aria-sort/light.png
- Registry item: https://we.inc/r/sortable-react-table-with-aria-sort.json
- Listed: 2026-10-29, source last changed 2026-10-29

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/sortable-react-table-with-aria-sort.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/sortable-react-table-with-aria-sort.tsx`

```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 &amp; 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>
  );
}
```
