ComponentsCSS
Table
Data table with hover rows, status-friendly cells and accessible sortable headers.
| Commit | Branch | Status | ||
|---|---|---|---|---|
| b81e07d | feat/pricing | Building | — | 1m |
| 4f2a91c | main | Ready | 41s | 3m |
| 9c3d552 | fix/nav-focus | Ready | 38s | 26m |
| e07a1f4 | main | Failed | 12s | 54m |
| 21bc9e0 | chore/deps | Ready | 47s | 2h |
Installation
npx shadcn@latest add https://ui-collections.vercel.app/r/glass/table.jsonAlso installs the theme, cn(). Packages: clsx, tailwind-merge.
Usage
table-demo.tsx
"use client";
import { useState } from "react";
import { Badge } from "@/registry/kit/ui/badge";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
type SortDirection,
} from "@/registry/kit/ui/table";
const deployments = [
{ id: "4f2a91c", branch: "main", status: "Ready", duration: 41, age: 3 },
{ id: "b81e07d", branch: "feat/pricing", status: "Building", duration: 0, age: 1 },
{ id: "9c3d552", branch: "fix/nav-focus", status: "Ready", duration: 38, age: 26 },
{ id: "e07a1f4", branch: "main", status: "Failed", duration: 12, age: 54 },
{ id: "21bc9e0", branch: "chore/deps", status: "Ready", duration: 47, age: 120 },
];
type Key = "duration" | "age";
const tone = { Ready: "success", Building: "warning", Failed: "danger" } as const;
export default function TableDemo() {
const [sort, setSort] = useState<{ key: Key; dir: Exclude<SortDirection, false> }>({ key: "age", dir: "ascending" });
const rows = [...deployments].sort((a, b) => (sort.dir === "ascending" ? 1 : -1) * (a[sort.key] - b[sort.key]));
const toggle = (key: Key) =>
setSort((s) => ({ key, dir: s.key === key && s.dir === "ascending" ? "descending" : "ascending" }));
const dir = (key: Key) => (sort.key === key ? sort.dir : false);
return (
<div className="w-full max-w-2xl">
<Table>
<TableCaption>Recent deployments of marketing-site</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Commit</TableHead>
<TableHead>Branch</TableHead>
<TableHead>Status</TableHead>
<TableHead sort={dir("duration")} onSort={() => toggle("duration")} className="text-right">
Build
</TableHead>
<TableHead sort={dir("age")} onSort={() => toggle("age")} className="text-right">
Age
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.id}</TableCell>
<TableCell>{d.branch}</TableCell>
<TableCell>
<Badge variant={tone[d.status as keyof typeof tone]} dot>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">{d.duration ? `${d.duration}s` : "—"}</TableCell>
<TableCell className="text-right text-muted tabular-nums">
{d.age < 60 ? `${d.age}m` : `${Math.round(d.age / 60)}h`}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}API
<TableHead sort onSort>"ascending" | "descending" | falseMakes the header a sort button with aria-sort.
PartsHeader, Body, Row, Head, Cell, CaptionSemantic table elements.
Source
components/ui/table.tsx
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { cn } from "@/registry/kit/lib/utils";
export interface TableProps extends HTMLAttributes<HTMLTableElement> {
/** Draw the surface frame. Turn off for a table that already sits inside a panel. */
frame?: boolean;
}
/** Surface frame; the table scrolls horizontally inside it on narrow screens. */
export function Table({ className, frame = true, ...props }: TableProps) {
const table = (
<div className="overflow-x-auto">
<table className={cn("w-full border-collapse text-left text-sm", className)} {...props} />
</div>
);
return frame ? <div className="surface overflow-hidden rounded-card">{table}</div> : table;
}
export function TableHeader({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("border-b border-line bg-fg/[0.03]", className)} {...props} />;
}
export function TableBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&>tr:not(:last-child)]:border-b [&>tr]:border-fg/[0.06]", className)} {...props} />;
}
export function TableRow({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={cn("transition-colors duration-200 hover:bg-fg/[0.04] data-[selected=true]:bg-accent/[0.07]", className)}
{...props}
/>
);
}
export type SortDirection = "ascending" | "descending" | false;
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
/** Makes the header a sort button showing this direction. */
sort?: SortDirection;
onSort?: () => void;
}
export function TableHead({ className, sort, onSort, children, ...props }: TableHeadProps) {
const sortable = onSort !== undefined;
return (
<th
scope="col"
aria-sort={sortable ? sort || "none" : undefined}
className={cn("h-11 px-4 text-xs font-medium whitespace-nowrap text-muted", className)}
{...props}
>
{sortable ? (
<button
type="button"
onClick={onSort}
className="group/sort -mx-1.5 inline-flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:text-fg focus-visible:outline-2 focus-visible:outline-accent"
>
{children}
<svg
viewBox="0 0 24 24"
className={cn(
"size-3.5 transition-[rotate,opacity] duration-300 ease-theme",
sort ? "text-accent opacity-100" : "opacity-0 group-hover/sort:opacity-60",
sort === "descending" && "rotate-180",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
</button>
) : (
children
)}
</th>
);
}
export function TableCell({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("px-4 py-3.5 align-middle text-fg/85", className)} {...props} />;
}
export function TableCaption({ className, ...props }: HTMLAttributes<HTMLTableCaptionElement>) {
return <caption className={cn("caption-bottom px-4 py-3 text-left text-xs text-muted", className)} {...props} />;
}