Simple table
A read-only table you hand columns and rows; no sorting, filtering, or paging.
Server-compatible until you pass onRowClick, which needs a client boundary of its own. A column with no cell renderer prints String(row[key] ?? ""), so a missing value is blank rather than the word undefined. Right-aligned columns get tabular figures automatically. The caption sits above the header band as the panel's label, not under the last row. Reach for DataTable instead as soon as the reader needs to sort, filter, or page.
Install
$
npx shadcn@latest add @vibra/simple-tableNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatCurrency, formatNumber } from "@/lib/format"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
type Region = {
region: string
orders: number
revenue: number
refundRate: number
}
const regions: Region[] = [
{ region: "North America", orders: 12840, revenue: 1284900, refundRate: 0.021 },
{ region: "Europe", orders: 9631, revenue: 918220, refundRate: 0.017 },
{ region: "Asia Pacific", orders: 7412, revenue: 604510, refundRate: 0.034 },
{ region: "Latin America", orders: 3186, revenue: 241080, refundRate: 0.028 },
{ region: "Middle East & Africa", orders: 1497, revenue: 132640, refundRate: 0.012 },
]
const totals = regions.reduce(
(sum, region) => ({ orders: sum.orders + region.orders, revenue: sum.revenue + region.revenue }),
{ orders: 0, revenue: 0 }
)
const columns: SimpleTableColumn<Region>[] = [
{ key: "region", header: "Region" },
{
key: "orders",
header: "Orders",
align: "right",
cell: (region) => formatNumber(region.orders, { maximumFractionDigits: 0 }),
},
{
key: "revenue",
header: "Revenue",
align: "right",
cell: (region) => formatCurrency(region.revenue, "USD", { maximumFractionDigits: 0 }),
},
{
key: "refundRate",
header: "Refunds",
align: "right",
cell: (region) => `${(region.refundRate * 100).toFixed(1)}%`,
},
]
export default function SimpleTableDemo() {
return (
<SimpleTable
className="w-full"
columns={columns}
rows={regions}
rowKey="region"
caption="Orders by region, last 30 days"
hoverable
footer={
<>
<td className="px-3 py-2 font-medium">All regions</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">
{formatNumber(totals.orders, { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">
{formatCurrency(totals.revenue, "USD", { maximumFractionDigits: 0 })}
</td>
<td className="px-3 py-2 text-right font-medium tabular-nums">2.3%</td>
</>
}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| columns | SimpleTableColumn<T>[] | — | Each column's key, header, alignment, width, class, and optional cell renderer. |
| rows | T[] | — | The rows, rendered in the order given. |
| rowKey | keyof T | ((row: T) => string) | — | A property name or a function; it must be unique across rows. |
| caption | React.ReactNode | — | A label for the panel, rendered above the header band. |
| size | "sm" | "default" | "default" | sm drops rows from h-11 to h-9 and tightens the cell padding. |
| striped | boolean | false | Tints every other row, for wide tables where the eye loses the line. |
| hoverable | boolean | false | Highlights the row under the cursor; onRowClick turns this on by itself. |
| footer | React.ReactNode | — | Cells for one footer row — totals, averages, a count. |
| footerRows | React.ReactNode[] | — | Cells for each of several footer rows, top to bottom — a subtotal, a discount, the tax and the total. Takes the place of footer. |
| emptyMessage | React.ReactNode | — | Replaces the built-in empty state, which is announced either way. |
| onRowClick | (row: T) => void | — | Makes rows clickable and operable with Enter or Space. |
| className | string | — | Merged onto the bordered panel that wraps the table. |
Dependencies
Source
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { EmptyState } from "@/components/ui/empty-state"
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const simpleTableHeadVariants = cva(
"bg-surface text-xs font-medium whitespace-nowrap text-muted-foreground",
{
variants: {
size: { default: "h-9 px-3", sm: "h-8 px-2.5" },
align: { left: "text-start", center: "text-center", right: "text-end" },
},
defaultVariants: { size: "default", align: "left" },
}
)
const simpleTableCellVariants = cva("", {
variants: {
size: { default: "px-3", sm: "px-2.5" },
// Numbers sit right on tabular figures; text stays where the reader starts.
align: { left: "", center: "text-center", right: "text-end tabular-nums" },
},
defaultVariants: { size: "default", align: "left" },
})
const simpleTableRowVariants = cva("", {
variants: {
size: { default: "h-11", sm: "h-9" },
// COUPLED TO registry/vibra/ui/table.tsx: TableRow highlights on hover by
// default. A read-only summary table has nothing to hover towards, so the
// false branch cancels it rather than leaving rows twitching under the
// cursor; simple-table.test.tsx pins the data attribute that drives this.
hoverable: { true: "", false: "hover:bg-transparent" },
interactive: {
true: "cursor-pointer focus-visible:bg-muted/50 focus-ring-inset",
false: "",
},
},
defaultVariants: { size: "default", hoverable: false, interactive: false },
})
export type SimpleTableColumn<T> = {
/** Property to read when the column has no `cell`, and the column's own id. */
key: string
header: React.ReactNode
align?: "left" | "right" | "center"
/** Any CSS length; the browser treats it as a hint, not a hard constraint. */
width?: string
cell?: (row: T, index: number) => React.ReactNode
className?: string
}
export type SimpleTableProps<T> = React.ComponentProps<"div"> & {
columns: SimpleTableColumn<T>[]
rows: T[]
/** A property name or a function; must be unique across rows. */
rowKey: keyof T | ((row: T) => string)
caption?: React.ReactNode
size?: NonNullable<VariantProps<typeof simpleTableRowVariants>["size"]>
striped?: boolean
hoverable?: boolean
/** Cells for one footer row — totals, averages, a count. */
footer?: React.ReactNode
/**
* Cells for each of several footer rows, top to bottom — a subtotal, a
* discount, the tax and the total. Takes the place of `footer`.
*/
footerRows?: React.ReactNode[]
emptyMessage?: React.ReactNode
onRowClick?: (row: T) => void
}
/** A read-only table you hand rows and columns; no sorting, filtering, or paging. */
function SimpleTable<T extends object>({
className,
columns,
rows,
rowKey,
caption,
size = "default",
striped = false,
hoverable = false,
footer,
footerRows,
emptyMessage,
onRowClick,
...props
}: SimpleTableProps<T>) {
const interactive = Boolean(onRowClick)
const highlight = hoverable || interactive
const footers = footerRows ?? (footer ? [footer] : [])
const keyOf = (row: T, index: number) =>
typeof rowKey === "function" ? rowKey(row) : String(row[rowKey] ?? index)
return (
<div
data-slot="simple-table"
data-size={size}
data-striped={striped || undefined}
data-hoverable={hoverable || undefined}
className={cn(
"w-full overflow-hidden rounded-lg border",
striped && "[&_tbody>tr:nth-child(even)]:bg-surface/60",
className
)}
{...props}
>
{/* COUPLED TO registry/vibra/ui/table.tsx: the table primitive sets
caption-bottom. A caption here is a label for the panel, so it belongs
above the header band, not stranded under the last row. */}
<Table className={caption ? "caption-top" : undefined}>
{caption ? (
<TableCaption className="mt-0 border-b bg-surface px-3 py-2 text-start text-xs font-medium text-foreground">
{caption}
</TableCaption>
) : null}
<TableHeader>
<TableRow className="hover:bg-transparent">
{columns.map((column) => (
<TableHead
key={column.key}
data-align={column.align ?? "left"}
style={column.width ? { width: column.width } : undefined}
className={cn(
simpleTableHeadVariants({ size, align: column.align }),
column.className
)}
>
{column.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="p-0">
<div
data-slot="simple-table-empty"
role="status"
className="flex items-center justify-center"
>
{emptyMessage === undefined ? (
<EmptyState size="sm" title="No rows" description="Nothing to show here yet." />
) : (
<span className="py-6 text-sm text-muted-foreground">{emptyMessage}</span>
)}
</div>
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow
key={keyOf(row, index)}
tabIndex={interactive ? 0 : undefined}
onClick={interactive ? () => onRowClick?.(row) : undefined}
onKeyDown={
interactive
? (event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
onRowClick?.(row)
}
: undefined
}
className={simpleTableRowVariants({ size, hoverable: highlight, interactive })}
>
{columns.map((column) => (
<TableCell
key={column.key}
data-align={column.align ?? "left"}
className={cn(
simpleTableCellVariants({ size, align: column.align }),
column.className
)}
>
{column.cell
? column.cell(row, index)
: String((row as Record<string, unknown>)[column.key] ?? "")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
{footers.length > 0 ? (
<TableFooter>
{footers.map((cells, index) => (
<TableRow key={index} className="hover:bg-transparent">
{cells}
</TableRow>
))}
</TableFooter>
) : null}
</Table>
</div>
)
}
export { SimpleTable, simpleTableCellVariants, simpleTableHeadVariants, simpleTableRowVariants }