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
| Name | Role | Status | Last active |
|---|---|---|---|
| Priya Nair | Design engineer | Active | 2 minutes ago |
| Tomás Rivera | Frontend | Active | 1 hour ago |
| Hana Sato | Product | Away | Yesterday |
| Kwame Mensah | Backend | Active | 3 days ago |
| Leah Cohen | Founder | Invited | Never |
Five rows, four columns, no sorting. Status is plain text.
Sortable with a sticky header
| web | main | 94s | 10:42 |
| docs | fix/typo | 31s | 10:38 |
| api | main | 212s | 10:21 |
| web | feat/search | 118s | 09:57 |
| marketing | main | 66s | 09:40 |
| api | chore/deps | 240s | 09:12 |
| docs | main | 28s | 08:55 |
| web | main | 101s | 08:30 |
Dense rows in a fixed height box. Headers sort on click, arrows show on hover.
Empty state
| Invoice | Amount | Due |
|---|---|---|
| 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
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | SimpleTableColumn<T>[] | Column definitions: key, header, align, sortable, sortValue, render, className. | |
| rows | T[] | The data. Sorting never mutates it. | |
| getRowId | (row: T) => string | number | Stable id per row, used as the React key. | |
| caption | string | Visible caption under the table. | |
| stickyHeader | boolean | false | Keeps the header in view. Give the wrapper a height via className. |
| density | "default" | "dense" | "default" | Row height, 40px or 32px. |
| empty | ReactNode | "Nothing to show." | Shown instead of the body when rows is empty. |
| defaultSort | { key; direction: "asc" | "desc" } | null | null | Initial 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