Sortable Table
Original · freesortable-column table
byWeb InnoventixReact + Tailwind
tablesortabletables
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/table-sortable.jsontable-sortable.tsx
"use client";
import { useId, useMemo, useState, type ChangeEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Status = "Shipped" | "In progress" | "In review" | "Blocked";
type Priority = "P0" | "P1" | "P2" | "P3";
interface Project {
id: string;
code: string;
name: string;
lead: string;
status: Status;
priority: Priority;
coverage: number;
updated: string;
}
type ColumnKey = "name" | "lead" | "status" | "priority" | "coverage" | "updated";
type SortDir = "asc" | "desc";
interface ColumnDef {
key: ColumnKey;
label: string;
align: "left" | "right";
}
const STATUS_RANK: Record<Status, number> = {
Blocked: 0,
"In review": 1,
"In progress": 2,
Shipped: 3,
};
const PRIORITY_RANK: Record<Priority, number> = {
P0: 0,
P1: 1,
P2: 2,
P3: 3,
};
const COLUMNS: ColumnDef[] = [
{ key: "name", label: "Service", align: "left" },
{ key: "lead", label: "Lead", align: "left" },
{ key: "status", label: "Status", align: "left" },
{ key: "priority", label: "Priority", align: "left" },
{ key: "coverage", label: "Test coverage", align: "right" },
{ key: "updated", label: "Last deploy", align: "right" },
];
const PROJECTS: Project[] = [
{ id: "1", code: "SVC-2041", name: "Atlas Checkout", lead: "Priya Raman", status: "Shipped", priority: "P1", coverage: 98.4, updated: "2026-07-14" },
{ id: "2", code: "SVC-1088", name: "Beacon Auth", lead: "Marco Silva", status: "In review", priority: "P0", coverage: 91.2, updated: "2026-07-15" },
{ id: "3", code: "SVC-3310", name: "Comet Search", lead: "Lena Fischer", status: "Blocked", priority: "P2", coverage: 76.5, updated: "2026-07-09" },
{ id: "4", code: "SVC-2207", name: "Delta Billing", lead: "Omar Haddad", status: "In progress", priority: "P1", coverage: 84.0, updated: "2026-07-13" },
{ id: "5", code: "SVC-4102", name: "Echo Notifications", lead: "Yuki Tanaka", status: "Shipped", priority: "P3", coverage: 99.1, updated: "2026-07-11" },
{ id: "6", code: "SVC-2884", name: "Foxglove Analytics", lead: "Sara Novak", status: "In progress", priority: "P2", coverage: 68.7, updated: "2026-07-12" },
{ id: "7", code: "SVC-1550", name: "Granite Storage", lead: "Diego Morales", status: "In review", priority: "P1", coverage: 88.9, updated: "2026-07-15" },
{ id: "8", code: "SVC-1902", name: "Harbor Gateway", lead: "Nina Petrov", status: "Blocked", priority: "P0", coverage: 72.3, updated: "2026-07-08" },
{ id: "9", code: "SVC-3475", name: "Iris Onboarding", lead: "Aisha Khan", status: "In progress", priority: "P1", coverage: 81.6, updated: "2026-07-10" },
{ id: "10", code: "SVC-2650", name: "Juniper Exports", lead: "Tomas Berg", status: "Shipped", priority: "P2", coverage: 94.7, updated: "2026-07-06" },
];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function formatDate(iso: string): string {
const parts = iso.split("-").map((n) => Number(n));
const year = parts[0];
const month = parts[1];
const day = parts[2];
return `${MONTHS[month - 1]} ${day}, ${year}`;
}
function compareByKey(a: Project, b: Project, key: ColumnKey): number {
switch (key) {
case "coverage":
return a.coverage - b.coverage;
case "updated":
return a.updated.localeCompare(b.updated);
case "status":
return STATUS_RANK[a.status] - STATUS_RANK[b.status];
case "priority":
return PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority];
case "lead":
return a.lead.localeCompare(b.lead);
default:
return a.name.localeCompare(b.name);
}
}
function statusClasses(status: Status): string {
switch (status) {
case "Shipped":
return "bg-emerald-100 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/25";
case "In progress":
return "bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25";
case "In review":
return "bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25";
case "Blocked":
return "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25";
}
}
function statusDot(status: Status): string {
switch (status) {
case "Shipped":
return "bg-emerald-500";
case "In progress":
return "bg-sky-500";
case "In review":
return "bg-amber-500";
case "Blocked":
return "bg-rose-500";
}
}
function priorityClasses(priority: Priority): string {
switch (priority) {
case "P0":
return "bg-rose-100 text-rose-700 ring-rose-600/20 dark:bg-rose-500/15 dark:text-rose-300 dark:ring-rose-400/25";
case "P1":
return "bg-amber-100 text-amber-800 ring-amber-600/20 dark:bg-amber-500/15 dark:text-amber-300 dark:ring-amber-400/25";
case "P2":
return "bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25";
case "P3":
return "bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-700/50 dark:text-slate-300 dark:ring-slate-500/25";
}
}
function coverageTone(value: number): { bar: string; text: string } {
if (value >= 90) return { bar: "bg-emerald-500", text: "text-emerald-600 dark:text-emerald-400" };
if (value >= 80) return { bar: "bg-sky-500", text: "text-sky-600 dark:text-sky-400" };
if (value >= 70) return { bar: "bg-amber-500", text: "text-amber-600 dark:text-amber-400" };
return { bar: "bg-rose-500", text: "text-rose-600 dark:text-rose-400" };
}
function SortGlyph({ state, reduce }: { state: SortDir | "none"; reduce: boolean }) {
if (state === "none") {
return (
<svg
viewBox="0 0 24 24"
width={14}
height={14}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="shrink-0 text-slate-400 opacity-60 transition-opacity group-hover:opacity-100 dark:text-slate-500"
>
<path d="M8 9l4-4 4 4" />
<path d="M16 15l-4 4-4-4" />
</svg>
);
}
return (
<motion.span
className="inline-flex shrink-0 text-indigo-500 dark:text-indigo-400"
style={{ transformOrigin: "center" }}
initial={false}
animate={{ rotate: state === "desc" ? 180 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 26 }}
>
<svg
viewBox="0 0 24 24"
width={14}
height={14}
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14" />
<path d="M6 11l6-6 6 6" />
</svg>
</motion.span>
);
}
export default function TableSortable() {
const reduce = useReducedMotion() ?? false;
const searchId = useId();
const captionId = useId();
const [sortKey, setSortKey] = useState<ColumnKey>("priority");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const [query, setQuery] = useState<string>("");
const rows = useMemo<Project[]>(() => {
const q = query.trim().toLowerCase();
const filtered = q
? PROJECTS.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.lead.toLowerCase().includes(q) ||
p.code.toLowerCase().includes(q) ||
p.status.toLowerCase().includes(q),
)
: PROJECTS.slice();
const factor = sortDir === "asc" ? 1 : -1;
return filtered.sort((a, b) => {
const primary = compareByKey(a, b, sortKey) * factor;
if (primary !== 0) return primary;
return a.name.localeCompare(b.name);
});
}, [query, sortKey, sortDir]);
const handleSort = (key: ColumnKey): void => {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
const activeLabel = COLUMNS.find((c) => c.key === sortKey)?.label ?? "";
const dirWord = sortDir === "asc" ? "ascending" : "descending";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:px-6 sm:py-20 lg:py-24 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes ts_rise {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
.ts-rise { animation: ts_rise 0.55s cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.ts-rise { animation: none; }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-28 left-1/2 h-64 w-[46rem] max-w-full -translate-x-1/2 rounded-full bg-indigo-400/10 blur-3xl dark:bg-indigo-500/10" />
<div className="absolute -bottom-24 right-0 h-56 w-96 rounded-full bg-violet-400/10 blur-3xl dark:bg-violet-500/10" />
</div>
<div className="relative mx-auto max-w-5xl">
<header className="ts-rise mb-8 flex flex-col gap-4 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="mb-2 inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Platform · Q3 2026
</p>
<h2 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Release readiness board
</h2>
<p className="mt-2 max-w-xl text-sm text-slate-600 dark:text-slate-400">
Ten services queued for the July freeze. Sort any column to triage — coverage before priority, or newest deploys first.
</p>
</div>
<div className="relative w-full sm:w-72">
<label htmlFor={searchId} className="sr-only">
Filter services by name, lead, code, or status
</label>
<span aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500">
<svg viewBox="0 0 24 24" width={18} height={18} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-3.6-3.6" />
</svg>
</span>
<input
id={searchId}
type="text"
inputMode="search"
value={query}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
placeholder="Filter services…"
className="w-full rounded-xl border border-slate-300 bg-white/80 py-2.5 pl-10 pr-10 text-sm text-slate-900 shadow-sm outline-none backdrop-blur transition placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus-visible:border-indigo-400"
/>
{query.length > 0 && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="Clear filter"
className="absolute right-2.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/60 dark:hover:bg-slate-800 dark:hover:text-slate-200"
>
<svg viewBox="0 0 24 24" width={15} height={15} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
)}
</div>
</header>
<div className="ts-rise overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 ring-1 ring-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20 dark:ring-white/5">
<div className="overflow-x-auto">
<table className="w-full min-w-[46rem] border-collapse text-left text-sm">
<caption id={captionId} className="sr-only">
Service release readiness, sortable by column. Currently sorted by {activeLabel}, {dirWord}.
</caption>
<thead>
<tr className="border-b border-slate-200 bg-slate-50/80 dark:border-slate-800 dark:bg-slate-800/40">
{COLUMNS.map((col) => {
const active = col.key === sortKey;
const ariaSort = active ? (sortDir === "asc" ? "ascending" : "descending") : "none";
const nextAction = active
? sortDir === "asc"
? "reverse to descending"
: "reverse to ascending"
: "sort ascending";
return (
<th
key={col.key}
scope="col"
aria-sort={ariaSort}
className="relative whitespace-nowrap px-4 py-0 font-medium"
>
<button
type="button"
onClick={() => handleSort(col.key)}
aria-label={`${col.label}: ${nextAction}`}
className={`group flex w-full items-center gap-1.5 py-3.5 text-xs font-semibold uppercase tracking-wider transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 ${
col.align === "right" ? "justify-end" : "justify-start"
} ${
active
? "text-slate-900 dark:text-slate-50"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
<span>{col.label}</span>
<SortGlyph state={active ? sortDir : "none"} reduce={reduce} />
</button>
{active && (
<motion.span
layoutId="ts-active-underline"
className="absolute inset-x-2 bottom-0 h-0.5 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }}
/>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length} className="px-4 py-14 text-center">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">No services match “{query}”.</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">Try a different name, lead, or status.</p>
</td>
</tr>
) : (
rows.map((p) => {
const tone = coverageTone(p.coverage);
return (
<tr
key={p.id}
className="border-b border-slate-100 transition-colors last:border-0 hover:bg-slate-50 dark:border-slate-800/70 dark:hover:bg-slate-800/40"
>
<td className="px-4 py-3.5 align-middle">
<div className="flex flex-col">
<span className="font-semibold text-slate-900 dark:text-slate-100">{p.name}</span>
<span className="mt-0.5 font-mono text-[0.7rem] text-slate-400 dark:text-slate-500">{p.code}</span>
</div>
</td>
<td className="px-4 py-3.5 align-middle text-slate-600 dark:text-slate-300">{p.lead}</td>
<td className="px-4 py-3.5 align-middle">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ${statusClasses(p.status)}`}>
<span className={`h-1.5 w-1.5 rounded-full ${statusDot(p.status)}`} />
{p.status}
</span>
</td>
<td className="px-4 py-3.5 align-middle">
<span className={`inline-flex items-center rounded-md px-2 py-0.5 font-mono text-xs font-semibold ring-1 ring-inset ${priorityClasses(p.priority)}`}>
{p.priority}
</span>
</td>
<td className="px-4 py-3.5 align-middle">
<div className="flex flex-col items-end gap-1.5">
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{p.coverage.toFixed(1)}%</span>
<span className="h-1.5 w-24 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<span className={`block h-full rounded-full ${tone.bar}`} style={{ width: `${p.coverage}%` }} />
</span>
</div>
</td>
<td className="px-4 py-3.5 text-right align-middle tabular-nums text-slate-600 dark:text-slate-300">
{formatDate(p.updated)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="flex flex-col gap-2 border-t border-slate-200 bg-slate-50/60 px-4 py-3 text-xs text-slate-500 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800 dark:bg-slate-800/30 dark:text-slate-400">
<span>
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{rows.length}</span> of {PROJECTS.length} services
</span>
<span className="hidden sm:inline">Sorted by {activeLabel}, {dirWord} · click a header or press Enter to re-sort</span>
</div>
</div>
<p className="sr-only" role="status" aria-live="polite">
{rows.length} {rows.length === 1 ? "service" : "services"} shown, sorted by {activeLabel}, {dirWord}.
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Table
Originalclean basic data table

Striped Table
Originalstriped-row table

Bordered Table
Originalbordered table

Hover Table
Originalrow-hover highlight table

Selectable Table
Originaltable with row checkboxes and select-all

Sticky Header Table
Originalsticky header scroll table

Pagination Table
Originaltable with footer pagination

Search Table
Originaltable with a search filter

Expandable Table
Originaltable with expandable rows

Actions Table
Originaltable with row action menus

Status Badges Table
Originaltable with status badges

Compact Table
Originaldense compact table

