Web InnoventixFreeCode

Sortable Table

Original · free

sortable-column table

byWeb InnoventixReact + Tailwind
tablesortabletables
Open

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.json
table-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 quote

Similar components

Browse all →