Sortable Data Table with Status Badges and Row Actions

An invoice table with seven rows. Client, Issued and Amount can be sorted by clicking their headings, and a small pair of arrows shows the current direction. Each row has a status badge (Paid, Pending, Overdue or Draft) and a three-dot button that opens a menu with four actions. It starts sorted by amount, largest first. On phones the invoice number, date and badge columns are hidden and the status moves under the client name, so the table fits a 360 pixel screen without scrolling sideways.

Code

import { useState } from "react";

type Status = "Paid" | "Pending" | "Overdue" | "Draft";
type SortKey = "client" | "issued" | "amount";

const invoices: { id: string; client: string; contact: string; issued: string; amount: number; status: Status }[] = [
  { id: "INV-2048", client: "Fieldnorth Outfitters", contact: "Lena Aldous", issued: "2025-03-11", amount: 4820, status: "Pending" },
  { id: "INV-2047", client: "Marlow Coffee", contact: "Idris Vane", issued: "2025-03-08", amount: 1260, status: "Paid" },
  { id: "INV-2046", client: "Brightwell Dental", contact: "Dr. Mina Sorel", issued: "2025-03-04", amount: 7400, status: "Paid" },
  { id: "INV-2045", client: "Tideline Kayaks", contact: "Cormac Hale", issued: "2025-02-21", amount: 915, status: "Overdue" },
  { id: "INV-2044", client: "Oakhurst Joinery", contact: "Beatrix Lund", issued: "2025-02-17", amount: 3350, status: "Paid" },
  { id: "INV-2043", client: "Paper Kite Books", contact: "Ansel Whitcombe", issued: "2025-02-09", amount: 2180, status: "Overdue" },
  { id: "INV-2042", client: "Sable and Rye Bakery", contact: "Odette Farrow", issued: "2025-02-02", amount: 640, status: "Draft" },
];

const badges: Record<Status, { pill: string; dot: string }> = {
  Paid: { pill: "bg-emerald-50 text-emerald-800 ring-emerald-200", dot: "bg-emerald-600" },
  Pending: { pill: "bg-amber-50 text-amber-800 ring-amber-200", dot: "bg-amber-500" },
  Overdue: { pill: "bg-rose-50 text-rose-800 ring-rose-200", dot: "bg-rose-600" },
  Draft: { pill: "bg-slate-100 text-slate-700 ring-slate-200", dot: "bg-slate-500" },
};

const actions = ["View invoice", "Send reminder", "Download PDF", "Void invoice"];

const money = (value: number) => `$${value.toLocaleString("en-US")}.00`;
const day = (iso: string) => new Date(`${iso}T00:00:00Z`).toLocaleDateString("en-US", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });

export default function SortableDataTableWithStatusBadgesAndRowActions() {
  const [sort, setSort] = useState<{ key: SortKey; dir: "asc" | "desc" }>({ key: "amount", dir: "desc" });
  const [menu, setMenu] = useState<string | null>(null);

  const rows = [...invoices].sort((a, b) => {
    const order = sort.key === "amount" ? a.amount - b.amount : a[sort.key].localeCompare(b[sort.key]);
    return sort.dir === "asc" ? order : -order;
  });

  const sortBy = (key: SortKey) =>
    setSort(sort.key === key ? { key, dir: sort.dir === "asc" ? "desc" : "asc" } : { key, dir: key === "client" ? "asc" : "desc" });

  const heading = (key: SortKey, label: string, align: "left" | "right", extra = "") => {
    const active = sort.key === key;
    return (
      <th scope="col" aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : "none"} className={`px-2 py-1.5 font-semibold first:pl-3 sm:px-3 sm:first:pl-4 ${extra}`}>
        <button
          type="button"
          onClick={() => sortBy(key)}
          className={`inline-flex min-h-[40px] items-center gap-1.5 rounded-md px-2 transition hover:bg-slate-200/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 ${align === "right" ? "flex-row-reverse" : ""} ${active ? "text-slate-900" : "text-slate-600"}`}
        >
          {label}
          <svg viewBox="0 0 12 16" fill="currentColor" aria-hidden="true" className="h-3.5 w-3 flex-none">
            <path d="M6 1.5 10 6.5H2z" className={active && sort.dir === "asc" ? "text-violet-600" : "text-slate-300"} />
            <path d="M6 14.5 2 9.5h8z" className={active && sort.dir === "desc" ? "text-violet-600" : "text-slate-300"} />
          </svg>
        </button>
      </th>
    );
  };

  return (
    <div className="bg-slate-50 px-4 py-10 text-slate-900 sm:px-8 sm:py-14">
      <div className="mx-auto max-w-5xl">
        <div className="flex items-end justify-between gap-4">
          <div>
            <h2 className="text-xl font-semibold tracking-tight">Invoices</h2>
            <p className="mt-1 text-sm text-slate-600">2 of 7 need a follow-up.</p>
          </div>
          <button type="button" className="min-h-[40px] flex-none rounded-lg bg-violet-600 px-4 text-sm font-semibold text-white transition hover:bg-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2">
            New invoice
          </button>
        </div>

        <div className="mt-6 rounded-xl border border-slate-200 bg-white">
          <table className="w-full text-left text-sm">
            <caption className="sr-only">Invoices, sortable by client, issue date and amount</caption>
            <thead className="border-b border-slate-200 text-xs">
              <tr className="bg-slate-50">
                {heading("client", "Client", "left", "rounded-tl-xl")}
                <th scope="col" className="hidden px-3 py-1.5 font-semibold text-slate-600 lg:table-cell">Invoice</th>
                {heading("issued", "Issued", "left", "hidden md:table-cell")}
                <th scope="col" className="hidden px-3 py-1.5 font-semibold text-slate-600 sm:table-cell">Status</th>
                {heading("amount", "Amount", "right", "w-px text-right sm:w-auto")}
                <th scope="col" className="w-px rounded-tr-xl px-1 py-1.5"><span className="sr-only">Actions</span></th>
              </tr>
            </thead>
            <tbody className="divide-y divide-slate-100">
              {rows.map((row, index) => {
                const badge = badges[row.status];
                const opensUp = index >= rows.length - 3;
                return (
                  <tr key={row.id} className="transition hover:bg-slate-50/70">
                    <th scope="row" className="max-w-0 py-3 pl-5 pr-2 font-normal sm:max-w-none sm:pl-6 sm:pr-3">
                      <p className="truncate font-semibold">{row.client}</p>
                      <p className="mt-0.5 hidden truncate text-slate-600 sm:block">{row.contact}</p>
                      <p className="mt-1 flex items-center gap-1.5 text-xs text-slate-600 sm:hidden">
                        <span aria-hidden="true" className={`h-1.5 w-1.5 flex-none rounded-full ${badge.dot}`} />
                        <span className="truncate">{row.status} ยท {row.id}</span>
                      </p>
                    </th>
                    <td className="hidden px-3 py-3 tabular-nums text-slate-600 lg:table-cell">{row.id}</td>
                    <td className="hidden whitespace-nowrap px-5 py-3 tabular-nums text-slate-600 md:table-cell">{day(row.issued)}</td>
                    <td className="hidden px-3 py-3 sm:table-cell">
                      <span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset ${badge.pill}`}>
                        <span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${badge.dot}`} />
                        {row.status}
                      </span>
                    </td>
                    <td className="w-px whitespace-nowrap py-3 pl-2 pr-4 text-right sm:w-auto font-semibold tabular-nums sm:pr-5">{money(row.amount)}</td>
                    <td className="w-px py-1 pl-0 pr-1 sm:pr-2">
                      <div
                        className="relative flex justify-end"
                        onKeyDown={(event) => { if (event.key === "Escape") setMenu(null); }}
                        onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setMenu(null); }}
                      >
                        <button
                          type="button"
                          aria-label={`Actions for ${row.id}, ${row.client}`}
                          aria-haspopup="menu"
                          aria-expanded={menu === row.id}
                          onClick={() => setMenu(menu === row.id ? null : row.id)}
                          className={`flex h-10 w-10 items-center justify-center rounded-lg transition hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 ${menu === row.id ? "bg-slate-100 text-slate-900" : "text-slate-600"}`}
                        >
                          <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-5 w-5"><circle cx="10" cy="4.5" r="1.5" /><circle cx="10" cy="10" r="1.5" /><circle cx="10" cy="15.5" r="1.5" /></svg>
                        </button>
                        {menu === row.id && (
                          <ul role="menu" aria-label={`Actions for ${row.id}`} className={`absolute right-0 z-10 w-48 rounded-xl border border-slate-200 bg-white p-1 text-left shadow-lg shadow-slate-900/10 ${opensUp ? "bottom-11" : "top-11"}`}>
                            {actions.map((action) => (
                              <li key={action} role="none">
                                <button type="button" role="menuitem" onClick={() => setMenu(null)} className={`flex min-h-[40px] w-full items-center rounded-lg px-3 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-violet-600 ${action.startsWith("Void") ? "text-rose-700 hover:bg-rose-50" : "text-slate-800 hover:bg-slate-100"}`}>
                                  {action}
                                </button>
                              </li>
                            ))}
                          </ul>
                        )}
                      </div>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/sortable-data-table-with-status-badges-and-row-actions.json

When to use it

How to customize it

Accessibility

Questions

How do I sort a table in React?

Keep the sort column and direction in state, copy the rows with the spread operator, and sort the copy before rendering. Compare numbers by subtraction and text with `localeCompare`. Copying first matters, because sorting the original array in place can cause display bugs.

How do I make a wide table work on phones?

Either let the table scroll sideways inside its own container, or hide less important columns and fold their content into another cell. This table does the second: the status and invoice number appear under the client name on small screens.

How do I sort by date correctly?

Store dates in year-month-day form, such as 2025-03-11, and sort that text. It orders correctly as plain text. Format it for display separately, as the `day` helper does here.

Should sorting happen in the browser or on the server?

Sort in the browser when all rows are already loaded, up to a few hundred. With pagination or thousands of rows, send the column and direction to the server, because the browser only has one page of the data.

Why does the menu open upward on the last rows?

So it does not run past the bottom of the table. The last three rows place the menu above the button with `bottom-11`, and the others place it below with `top-11`.

Related: Pagination with Previous, Next and Page Size Select, Underline Tabs with Icons and Three Panels, Button Set: Primary, Secondary, Ghost, Icon and Loading, Pricing Table with Monthly and Yearly Toggle.

More tables and lists or all free UI components. Start for free to have a full site built around it.

More in Tables

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap