A semantic table in a focusable, named scroll region, with sticky header, striping, three densities, aria-sort headers and numeric columns.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-1042 | Northwind Studio | Paid | $1,250.00 |
| INV-1043 | Bluebird Labs | Pending | $480.00 |
| INV-1044 | Harbor & Co | Paid | $2,310.00 |
| INV-1045 | Kilo Systems | Overdue | $890.00 |
| Total | $4,930.00 | ||
import { Badge } from "@/components/ballmac/badge";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ballmac/table";
const invoices = [
{ id: "INV-1042", customer: "Northwind Studio", status: "Paid", amount: 1250 },
{ id: "INV-1043", customer: "Bluebird Labs", status: "Pending", amount: 480 },
{ id: "INV-1044", customer: "Harbor & Co", status: "Paid", amount: 2310 },
{ id: "INV-1045", customer: "Kilo Systems", status: "Overdue", amount: 890 },
];
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
export default function TableDemo() {
const total = invoices.reduce((sum, i) => sum + i.amount, 0);
return (
<div className="w-full max-w-lg">
<Table label="Recent invoices" striped>
<TableCaption>Invoices from the last 30 days</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead numeric>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((i) => (
<TableRow key={i.id}>
<TableCell className="font-mono text-xs">{i.id}</TableCell>
<TableCell className="font-medium">{i.customer}</TableCell>
<TableCell>
<Badge
variant="outline"
status={i.status === "Paid" ? "success" : i.status === "Pending" ? "warning" : "error"}
dot
>
{i.status}
</Badge>
</TableCell>
<TableCell numeric>{money.format(i.amount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell numeric>{money.format(total)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/tableInstall the dependencies.
$ pnpm add lucide-reactCopy the source into your project.
components/ballmac/table.tsx// Ballmac UI: Table. https://ui.ballmac.com/components/table // Based on shadcn/ui Table (MIT, Copyright (c) 2023 shadcn), adding a focusable scroll region, sticky header, striping, density, and aria-sort. import * as React from "react"; import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; type TableProps = React.ComponentProps<"table"> & { /** Accessible name of the scroll region that wraps the table. Recommended whenever the table can scroll. */ label?: string; /** Keep the header row visible while the body scrolls (give the table a max height with `containerClassName`). */ stickyHeader?: boolean; /** Shade every second body row. */ striped?: boolean; /** Row height. */ density?: "compact" | "default" | "comfortable"; /** Classes for the scroll container, for example `max-h-80`. */ containerClassName?: string; }; function Table({ className, containerClassName, label, stickyHeader = false, striped = false, density = "default", ...props }: TableProps) { return ( <div data-slot="table-container" role="region" aria-label={label} tabIndex={0} className={cn( "relative w-full overflow-auto rounded-xl border bg-card outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", containerClassName, )} > <table data-slot="table" data-sticky={stickyHeader ? "" : undefined} data-striped={striped ? "" : undefined} data-density={density} className={cn( "group/table w-full caption-bottom border-separate border-spacing-0 text-sm", className, )} {...props} /> </div> ); } type TableHeaderProps = React.ComponentProps<"thead">; function TableHeader({ className, ...props }: TableHeaderProps) { return ( <thead data-slot="table-header" className={cn( "bg-muted/50 group-data-[sticky]/table:sticky group-data-[sticky]/table:top-0 group-data-[sticky]/table:z-10 group-data-[sticky]/table:bg-muted", className, )} {...props} /> ); } type TableBodyProps = React.ComponentProps<"tbody">; function TableBody({ className, ...props }: TableBodyProps) { return ( <tbody data-slot="table-body" className={cn("[&_tr:last-child_td]:border-b-0", className)} {...props} /> ); } type TableFooterProps = React.ComponentProps<"tfoot">; function TableFooter({ className, ...props }: TableFooterProps) { return ( <tfoot data-slot="table-footer" className={cn("bg-muted/50 font-medium [&_td]:border-t [&_td]:border-b-0", className)} {...props} /> ); } type TableRowProps = React.ComponentProps<"tr"> & { /** Mark the row as selected (also sets `aria-selected`). */ selected?: boolean; }; function TableRow({ className, selected, ...props }: TableRowProps) { return ( <tr data-slot="table-row" data-state={selected ? "selected" : undefined} aria-selected={selected || undefined} className={cn( "transition-colors hover:bg-muted/50 group-data-[striped]/table:even:bg-muted/30 data-[state=selected]:bg-accent", className, )} {...props} /> ); } type TableHeadProps = React.ComponentProps<"th"> & { /** Sort state of this column. Sets `aria-sort` and shows a direction icon; wrap the label in a button to make it interactive. */ sort?: "ascending" | "descending" | "none"; /** Right-align and use tabular numbers. */ numeric?: boolean; /** Draw the direction icon when `sort` is set. Turn off when your own header button already shows one. */ sortIcon?: boolean; }; function TableHead({ className, sort, sortIcon = true, numeric, children, scope = "col", ...props }: TableHeadProps) { const Icon = sort === "ascending" ? ArrowUp : sort === "descending" ? ArrowDown : ChevronsUpDown; return ( <th data-slot="table-head" scope={scope} aria-sort={sort} className={cn( "h-10 border-b px-3 text-start align-middle text-xs font-medium whitespace-nowrap text-muted-foreground", numeric && "text-end tabular-nums", className, )} {...props} > {sort && sortIcon ? ( <span className={cn( "inline-flex items-center gap-1.5", numeric && "flex-row-reverse", )} > {children} <Icon aria-hidden="true" className="size-3.5 opacity-70" /> </span> ) : ( children )} </th> ); } type TableCellProps = React.ComponentProps<"td"> & { /** Right-align and use tabular numbers. */ numeric?: boolean; }; function TableCell({ className, numeric, ...props }: TableCellProps) { return ( <td data-slot="table-cell" className={cn( "border-b px-3 align-middle group-data-[density=compact]/table:h-9 group-data-[density=default]/table:h-12 group-data-[density=comfortable]/table:h-16", numeric && "text-end tabular-nums", className, )} {...props} /> ); } type TableCaptionProps = React.ComponentProps<"caption">; function TableCaption({ className, ...props }: TableCaptionProps) { return ( <caption data-slot="table-caption" className={cn("mt-3 mb-3 text-xs text-muted-foreground", className)} {...props} /> ); } export { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption, type TableProps, type TableHeaderProps, type TableBodyProps, type TableFooterProps, type TableRowProps, type TableHeadProps, type TableCellProps, type TableCaptionProps, };Update the import paths to match your project setup.
Usage
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from "@/components/ballmac/table"The full example is in the Code tab above.
Examples
Sortable and sticky
| Name | Team | |
|---|---|---|
| Kofi Mensah | Platform | 342 |
| Sam Okafor | Platform | 215 |
| Ivy Chen | Design | 154 |
| Ana Lima | Design | 128 |
| Mei Tanaka | Growth | 87 |
| Leo Duarte | Growth | 63 |
"use client";
import * as React from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ballmac/table";
const rows = [
{ name: "Ana Lima", team: "Design", commits: 128 },
{ name: "Kofi Mensah", team: "Platform", commits: 342 },
{ name: "Mei Tanaka", team: "Growth", commits: 87 },
{ name: "Sam Okafor", team: "Platform", commits: 215 },
{ name: "Ivy Chen", team: "Design", commits: 154 },
{ name: "Leo Duarte", team: "Growth", commits: 63 },
];
export default function TableStates() {
const [dir, setDir] = React.useState<"ascending" | "descending">("descending");
const [selected, setSelected] = React.useState("Kofi Mensah");
const sorted = [...rows].sort((a, b) =>
dir === "ascending" ? a.commits - b.commits : b.commits - a.commits,
);
return (
<div className="w-full max-w-md">
<Table label="Contributors" stickyHeader density="compact" containerClassName="max-h-60">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Team</TableHead>
<TableHead numeric sort={dir}>
<button
type="button"
onClick={() => setDir(dir === "ascending" ? "descending" : "ascending")}
className="rounded outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Commits
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((r) => (
<TableRow key={r.name} selected={selected === r.name} onClick={() => setSelected(r.name)}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell className="text-muted-foreground">{r.team}</TableCell>
<TableCell numeric>{r.commits}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}API reference
<Table>
| Prop | Type | Default |
|---|---|---|
labelAccessible name of the scroll region that wraps the table. Recommended whenever the table can scroll. | string | — |
stickyHeaderKeep the header row visible while the body scrolls (give the table a max height with `containerClassName`). | boolean | false |
stripedShade every second body row. | boolean | false |
densityRow height. | "compact" | "default" | "comfortable" | "default" |
containerClassNameClasses for the scroll container, for example `max-h-80`. | string | — |
<TableRow>
| Prop | Type | Default |
|---|---|---|
selectedMark the row as selected (also sets `aria-selected`). | boolean | — |
<TableHead>
| Prop | Type | Default |
|---|---|---|
sortSort state of this column. Sets `aria-sort` and shows a direction icon; wrap the label in a button to make it interactive. | "ascending" | "descending" | "none" | — |
numericRight-align and use tabular numbers. | boolean | — |
sortIconDraw the direction icon when `sort` is set. Turn off when your own header button already shows one. | boolean | true |
<TableCell>
| Prop | Type | Default |
|---|---|---|
numericRight-align and use tabular numbers. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the scroll region, then any controls inside it |
| Arrow keys | Scroll the focused region |
Use with AI
Static table parts. The wrapper scrolls on narrow screens and is keyboard focusable; pass label to name it and sort on TableHead for aria-sort. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Table (@ballmac/table) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Invoices, logs and lists of records
- Read-only or lightly interactive data
Not for
- Sorting, filtering and pagination logic; build on data-table
- Key-value pairs; use description-list
Registry JSON: https://ui.ballmac.com/r/table.json
Credits
Based on shadcn/ui Table, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- shadcn/utils
Pairs well with
Badge
A small pill label in four variants with an optional status dot and success, warning and error tones. Style links with badgeVariants().
Pagination
A semantic page navigation set with current-page feedback, generous targets, and small-screen labels.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Audio Player
A podcast-style audio player: play and pause, skip back and forward, a scrubber with chapter ticks, a named current chapter and a speed control. Works with a real file or as a silent demo.