Skip to content
goodtabs UI
BrowseBrowse components

Simple table

A typed data table with column sorting, a sticky header and an empty state.

npx shadcn@latest add https://ui.goodtabs.co/r/simple-table.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/simple-table

NameRoleStatusLast active
Priya NairDesign engineerActive2 minutes ago
Tomás RiveraFrontendActive1 hour ago
Hana SatoProductAwayYesterday
Kwame MensahBackendActive3 days ago
Leah CohenFounderInvitedNever

Five rows, four columns, no sorting. Status is plain text.

Sortable with a sticky header

Deploys today, newest first. Click a header to sort.
webmain94s10:42
docsfix/typo31s10:38
apimain212s10:21
webfeat/search118s09:57
marketingmain66s09:40
apichore/deps240s09:12
docsmain28s08:55
webmain101s08:30

Dense rows in a fixed height box. Headers sort on click, arrows show on hover.

Empty state

InvoiceAmountDue
No invoices yetThe first one appears here once a payment is due.

The empty slot replaces the body when there are no rows.

Usage

import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"

type Member = { id: number; name: string; role: string }

const columns: SimpleTableColumn<Member>[] = [
  { key: "name", header: "Name", sortable: true },
  { key: "role", header: "Role" },
]

<SimpleTable columns={columns} rows={members} getRowId={(m) => m.id} />

Props

PropTypeDefaultDescription
columnsSimpleTableColumn<T>[]Column definitions: key, header, align, sortable, sortValue, render, className.
rowsT[]The data. Sorting never mutates it.
getRowId(row: T) => string | numberStable id per row, used as the React key.
captionstringVisible caption under the table.
stickyHeaderbooleanfalseKeeps the header in view. Give the wrapper a height via className.
density"default" | "dense""default"Row height, 40px or 32px.
emptyReactNode"Nothing to show."Shown instead of the body when rows is empty.
defaultSort{ key; direction: "asc" | "desc" } | nullnullInitial sort column and direction.

Source

simple-table/simple-table.tsx
"use client";

import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon } from "lucide-react";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";

export type SimpleTableColumn<T> = {
  /** Key of the row value. Also the React key. */
  key: keyof T & string;
  /** Header label. */
  header: string;
  /** Text alignment. Numbers usually go right. */
  align?: "left" | "right" | "center";
  /** Turns the header into a sort button. Sorts by the raw value unless sortValue is given. */
  sortable?: boolean;
  /** Value used for sorting, when the raw value is not comparable. */
  sortValue?: (row: T) => string | number;
  /** Custom cell. Defaults to the raw value as text. */
  render?: (row: T) => React.ReactNode;
  /** Extra classes on the cells of this column. */
  className?: string;
};

type SortState<T> = { key: keyof T & string; direction: "asc" | "desc" } | null;

type SimpleTableProps<T> = Omit<React.ComponentProps<"div">, "children"> & {
  columns: SimpleTableColumn<T>[];
  rows: T[];
  getRowId: (row: T) => string | number;
  /** Visible caption under the table. */
  caption?: string;
  /** Keeps the header in view inside a scrolling container. Give the wrapper a height. */
  stickyHeader?: boolean;
  /** Row height. dense is 32px, default is 40px. */
  density?: "default" | "dense";
  /** Shown instead of the body when rows is empty. */
  empty?: React.ReactNode;
  /** Initial sort. */
  defaultSort?: SortState<T>;
};

/**
 * A typed table on shadcn's table primitives with client-side sorting.
 * Keep it for lists under a few hundred rows; beyond that, paginate on the server.
 */
export function SimpleTable<T>({
  columns,
  rows,
  getRowId,
  caption,
  stickyHeader = false,
  density = "default",
  empty,
  defaultSort = null,
  className,
  ...props
}: SimpleTableProps<T>) {
  const [sort, setSort] = React.useState<SortState<T>>(defaultSort);

  const sorted = React.useMemo(() => {
    if (!sort) return rows;
    const column = columns.find((c) => c.key === sort.key);
    if (!column) return rows;
    const value = (row: T) => column.sortValue?.(row) ?? (row[column.key] as unknown as string | number);
    const dir = sort.direction === "asc" ? 1 : -1;
    return [...rows].sort((a, b) => {
      const va = value(a);
      const vb = value(b);
      if (typeof va === "number" && typeof vb === "number") return (va - vb) * dir;
      return String(va).localeCompare(String(vb), undefined, { numeric: true, sensitivity: "base" }) * dir;
    });
  }, [rows, columns, sort]);

  const toggleSort = (key: keyof T & string) => {
    setSort((current) => {
      if (current?.key !== key) return { key, direction: "asc" };
      if (current.direction === "asc") return { key, direction: "desc" };
      return null;
    });
  };

  const cellHeight = density === "dense" ? "h-8" : "h-10";
  const alignClass = (align?: SimpleTableColumn<T>["align"]) =>
    align === "right" ? "text-right" : align === "center" ? "text-center" : "text-left";

  return (
    <div
      data-slot="simple-table"
      className={cn("rounded-lg border", stickyHeader && "overflow-auto", className)}
      {...props}
    >
      <Table>
        {caption && <TableCaption className="mt-0 mb-3 caption-bottom px-3">{caption}</TableCaption>}
        <TableHeader className={cn(stickyHeader && "sticky top-0 z-10 bg-background")}>
          <TableRow className="hover:bg-transparent">
            {columns.map((column) => {
              const active = sort?.key === column.key;
              const ariaSort = active
                ? sort.direction === "asc"
                  ? "ascending"
                  : "descending"
                : column.sortable
                  ? "none"
                  : undefined;
              const Icon = active ? (sort.direction === "asc" ? ArrowUpIcon : ArrowDownIcon) : ArrowUpDownIcon;
              return (
                <TableHead
                  key={column.key}
                  aria-sort={ariaSort}
                  className={cn("h-9 px-3 text-xs text-muted-foreground", alignClass(column.align), column.className)}
                >
                  {column.sortable ? (
                    <button
                      type="button"
                      onClick={() => toggleSort(column.key)}
                      className={cn(
                        "group/sort -mx-1.5 inline-flex h-7 items-center gap-1 rounded-sm px-1.5 transition-colors duration-100 ease-out hover:bg-muted hover:text-foreground",
                        active && "text-foreground",
                        column.align === "right" && "flex-row-reverse",
                      )}
                    >
                      {column.header}
                      <Icon
                        aria-hidden="true"
                        className={cn(
                          "size-3.5 transition-opacity duration-100 ease-out",
                          active
                            ? "opacity-100"
                            : "opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100",
                        )}
                      />
                      <span className="sr-only">
                        {active ? (sort.direction === "asc" ? ", sorted ascending" : ", sorted descending") : ", sort"}
                      </span>
                    </button>
                  ) : (
                    column.header
                  )}
                </TableHead>
              );
            })}
          </TableRow>
        </TableHeader>
        <TableBody>
          {sorted.length === 0 ? (
            <TableRow className="hover:bg-transparent">
              <TableCell
                colSpan={columns.length}
                className="h-24 px-3 text-center whitespace-normal text-muted-foreground"
              >
                {empty ?? "Nothing to show."}
              </TableCell>
            </TableRow>
          ) : (
            sorted.map((row) => (
              <TableRow key={getRowId(row)}>
                {columns.map((column) => (
                  <TableCell
                    key={column.key}
                    className={cn(cellHeight, "px-3 py-0", alignClass(column.align), column.className)}
                  >
                    {column.render ? column.render(row) : String(row[column.key] ?? "")}
                  </TableCell>
                ))}
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/simple-table/prompt.md, or the Copy AI prompt button above.

When to use

  • Lists of records with a handful of columns: members, deploys, invoices, logs
  • When readers want to sort by a column without a round trip
  • Settings and admin screens where a full data grid would be too much

When not to use

  • More than a few hundred rows: paginate or virtualise on the server instead
  • Row selection, inline editing or column resizing: that is a data grid
  • Layout. Never use a table to arrange content

Agent notes

  • getRowId and render are functions, so the component that renders SimpleTable must be a client component ("use client"), or the build fails with a functions-to-client-components error
  • columns.key must be a key of the row type; the generic T is inferred from rows
  • Sorting compares numbers numerically and strings with localeCompare; pass sortValue when the raw value is not comparable, like a formatted date
  • stickyHeader only works when the wrapper has a fixed height and scrolls; pass h-64 or similar in className
  • Clicking a sorted header cycles ascending, descending, then unsorted