Data display
Table
A data table with hairline rows, muted 13px headers, numeric alignment, selected rows and an optional sticky header.
npx prodular-ui add tableIn a card
The card is the container. The table adds no box of its own and its edge columns line up with the card padding.
import { DownloadIcon } from "lucide-react"
import { Badge, BadgeDot } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
const invoices = [ { id: "INV-2048", customer: "Pinecrest Health", status: "Paid", issued: "Oct 18, 2026", amount: 12480 }, { id: "INV-2047", customer: "Tidewater Freight", status: "Past due", issued: "Oct 14, 2026", amount: 3920 }, { id: "INV-2046", customer: "Corvid Labs", status: "Paid", issued: "Oct 11, 2026", amount: 860.5 }, { id: "INV-2045", customer: "Marlow and Finch", status: "Failed", issued: "Oct 09, 2026", amount: 2150 }, { id: "INV-2044", customer: "Halden Analytics", status: "Scheduled", issued: "Oct 02, 2026", amount: 7300 },] as const
const statusVariant = { Paid: "success", "Past due": "warning", Failed: "destructive", Scheduled: "info",} as const
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export default function TableInvoices() { const total = invoices.reduce((sum, i) => sum + i.amount, 0) return ( <Card className="w-full"> <CardHeader> <CardTitle>Invoices</CardTitle> <CardDescription>Issued in the last 30 days</CardDescription> <CardAction> <Button variant="outline" size="sm"> <DownloadIcon data-icon="inline-start" /> Export CSV </Button> </CardAction> </CardHeader> <CardContent> <Table> <TableHeader> <TableRow> <TableHead>Invoice</TableHead> <TableHead>Customer</TableHead> <TableHead>Status</TableHead> <TableHead>Issued</TableHead> <TableHead numeric>Amount</TableHead> </TableRow> </TableHeader> <TableBody> {invoices.map((invoice) => ( <TableRow key={invoice.id} data-state={invoice.id === "INV-2047" ? "selected" : undefined} > <TableCell className="font-mono text-[13px] text-muted-foreground">{invoice.id}</TableCell> <TableCell className="font-medium">{invoice.customer}</TableCell> <TableCell> <Badge size="sm" variant={statusVariant[invoice.status]}> <BadgeDot /> {invoice.status} </Badge> </TableCell> <TableCell className="text-muted-foreground">{invoice.issued}</TableCell> <TableCell numeric>{usd.format(invoice.amount)}</TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell colSpan={4}>Total</TableCell> <TableCell numeric>{usd.format(total)}</TableCell> </TableRow> </TableFooter> </Table> </CardContent> </Card> )}Sticky header
Set a max height on the container and pass sticky to keep the header in view.
import { Badge } from "@/components/ui/badge"import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
const endpoints = ["/v1/invoices", "/v1/customers", "/v1/events", "/v1/usage", "/v1/webhooks"]const methods = ["GET", "POST", "GET", "GET", "PATCH"]const codes = [200, 200, 201, 200, 429, 200, 500, 200, 204, 200, 200, 404]
const requests = Array.from({ length: 16 }, (_, i) => ({ id: `req_${(0x3f9a2c + i * 7919).toString(16)}`, time: `14:${String(32 - Math.floor(i / 2)).padStart(2, "0")}:${String((59 - i * 7) % 60).padStart(2, "0")}`, method: methods[i % methods.length], endpoint: endpoints[(i * 3) % endpoints.length], status: codes[i % codes.length], latency: [84, 112, 96, 241, 38, 67, 1290, 73, 55, 188, 91, 46][i % 12],}))
function statusVariant(code: number) { if (code >= 500) return "destructive" as const if (code >= 400) return "warning" as const return "secondary" as const}
export default function TableSticky() { return ( <Card className="w-full"> <CardHeader> <CardTitle>API requests</CardTitle> <CardDescription>Live tail for the production key</CardDescription> </CardHeader> <CardContent> <Table containerClassName="max-h-72"> <TableHeader sticky> <TableRow> <TableHead>Time</TableHead> <TableHead>Request</TableHead> <TableHead>Endpoint</TableHead> <TableHead>Status</TableHead> <TableHead numeric>Latency</TableHead> </TableRow> </TableHeader> <TableBody> {requests.map((r) => ( <TableRow key={r.id}> <TableCell className="text-muted-foreground tabular-nums">{r.time}</TableCell> <TableCell className="font-mono text-[13px] text-muted-foreground">{r.id}</TableCell> <TableCell> <span className="mr-2 text-[13px] font-medium text-muted-foreground">{r.method}</span> <span className="font-mono text-[13px]">{r.endpoint}</span> </TableCell> <TableCell> <Badge size="sm" variant={statusVariant(r.status)}> {r.status} </Badge> </TableCell> <TableCell numeric className={r.latency > 1000 ? "text-destructive-subtle-foreground" : undefined}> {r.latency.toLocaleString("en-US")} ms </TableCell> </TableRow> ))} </TableBody> </Table> </CardContent> </Card> )}Anatomy
<Card> <CardHeader> <CardTitle>Invoices</CardTitle> </CardHeader> <CardContent> <Table> <TableHeader> <TableRow> <TableHead>Customer</TableHead> <TableHead numeric>Amount</TableHead> </TableRow> </TableHeader> <TableBody> <TableRow> <TableCell>Pinecrest Health</TableCell> <TableCell numeric>$12,480.00</TableCell> </TableRow> </TableBody> </Table> </CardContent></Card>Guidance
Use it when
- Records people scan, compare and act on: invoices, members, requests, deployments.
- Put tables inside a Card with a title and the primary table action in CardAction.
- Right align every numeric column, including its header, so digits line up.
Avoid
- A border or a tinted wrapper around the table inside a card. The card is the only box.
- Zebra striping. Hairline row dividers and hover are enough.
- More than one badge or more than two lines of text per cell.
Rules for AI agents
Published in llms-full.txt and the registry, so coding agents follow them when they build with this component.
- Place Table directly inside CardContent. Do not wrap it in another bordered div. The edge padding aligns with the card automatically.
- Pass numeric to both TableHead and TableCell for amounts, counts, durations and percentages.
- Format currency with Intl.NumberFormat and put units in the cell (184 ms), not in a separate column.
- Mark the selected row with data-state="selected" or aria-selected. Do not add your own background classes.
- Use font-mono only for ids and hashes, never for names or dates.
- For long tables, pass containerClassName="max-h-96" to Table and sticky to TableHeader.
- Use Badge size="sm" inside cells.
API
Table
| Prop | Type | Default | Description |
|---|---|---|---|
| containerClassName | string | Classes for the scrolling wrapper. Set a max height here for a sticky header. |
TableHeader
| Prop | Type | Default | Description |
|---|---|---|---|
| sticky | boolean | false | Keeps the header row visible while the container scrolls. |
TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
| numeric | boolean | false | Right aligns the header to match numeric cells. |
TableCell
| Prop | Type | Default | Description |
|---|---|---|---|
| numeric | boolean | false | Right aligns the cell with tabular figures. |
TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
| data-state | "selected" | Highlights the row with the brand tint. aria-selected works too. |