Table cells
Cell renderers for numbers, money, ratios, dates, flags, links, and long text.
Server-compatible: no hooks, no client boundary. Every cell renders an em dash for a null or undefined value, so a hole in the data never reads as a zero. Numbers are tabular and set at the line's end by default, which is what keeps a column of digits scannable; the end is logical, so align "right" is the left of a right-to-left table, under its numeric title. DateCell relative reads the clock at render time and suppresses the hydration warning; the exact date stays in the title attribute either way.
Install
$
npx shadcn@latest add @vibra/table-cellsNeeds the @vibra registry in your components.json — set it up once.
Examples
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import {
BooleanCell,
CurrencyCell,
DateCell,
DeltaCell,
LinkCell,
NumberCell,
PercentCell,
TruncateCell,
} from "@/components/ui/table-cells"
type Plan = {
name: string
seats: number | null
revenue: number
share: number
change: number
updated: Date
selfServe: boolean | null
docs: string
}
const HOUR = 3_600_000
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const plans: Plan[] = [
{
name: "Starter — monthly, no commitment",
seats: 4820,
revenue: 96400,
share: 0.256,
change: 0.082,
updated: new Date(NOW - 2 * HOUR),
selfServe: true,
docs: "https://example.com/plans/starter",
},
{
name: "Team — annual, 10 seat minimum",
seats: 1965,
revenue: 214300,
share: 0.418,
change: 0.031,
updated: new Date(NOW - 9 * HOUR),
selfServe: true,
docs: "https://example.com/plans/team",
},
{
name: "Business — annual with support SLA",
seats: 612,
revenue: 158900,
share: 0.244,
change: -0.017,
updated: new Date(NOW - 31 * HOUR),
selfServe: false,
docs: "https://example.com/plans/business",
},
{
name: "Enterprise — negotiated, invoiced",
seats: null,
revenue: 41200,
share: 0.082,
change: 0.194,
updated: new Date(NOW - 76 * HOUR),
selfServe: null,
docs: "https://example.com/plans/enterprise",
},
]
const columns: SimpleTableColumn<Plan>[] = [
{
key: "name",
header: "Plan",
cell: (plan) => <TruncateCell maxWidth={200}>{plan.name}</TruncateCell>,
},
{ key: "seats", header: "Seats", align: "right", cell: (plan) => <NumberCell value={plan.seats} /> },
{
key: "revenue",
header: "MRR",
align: "right",
cell: (plan) => <CurrencyCell value={plan.revenue} compact />,
},
{
key: "share",
header: "Share",
align: "right",
width: "8rem",
cell: (plan) => <PercentCell value={plan.share} showBar />,
},
{
key: "change",
header: "Change",
align: "right",
cell: (plan) => <DeltaCell value={plan.change} />,
},
{
key: "updated",
header: "Updated",
align: "right",
cell: (plan) => <DateCell date={plan.updated} relative className="text-muted-foreground" />,
},
{
key: "selfServe",
header: "Self-serve",
cell: (plan) => <BooleanCell value={plan.selfServe} />,
},
{
key: "docs",
header: "Docs",
cell: (plan) => (
<LinkCell href={plan.docs} external>
Pricing
</LinkCell>
),
},
]
export default function TableCellsDemo() {
return (
<SimpleTable
className="w-full"
columns={columns}
rows={plans}
rowKey="name"
caption="One cell component per column"
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| NumberCell | { value: number | null | undefined; format?: (n: number) => string; align?: "left" | "right"; className?: string } | align: "right" | A separated number on tabular figures, e.g. 1234567.891 becomes 1,234,567.89. |
| CurrencyCell | { value: number | null | undefined; currency?: string; compact?: boolean; className?: string } | currency: "USD" | A money amount, e.g. 1234.5 becomes $1,234.50; compact abbreviates large ones. |
| PercentCell | { value: number | null | undefined; showBar?: boolean; className?: string } | showBar: false | A 0..1 ratio as a percentage, e.g. 0.256 becomes 25.6%, over an optional bar. |
| DateCell | { date: Date | string | number | null | undefined; format?: "short" | "medium" | "long"; relative?: boolean; className?: string } | format: "medium" | A date, absolute or relative, always with the full date in its title. |
| BooleanCell | { value: boolean | null | undefined; trueLabel?: string; falseLabel?: string; className?: string } | trueLabel: "Yes", falseLabel: "No" | An icon plus its word, so a flag never reads by glyph alone. |
| LinkCell | React.ComponentProps<"a"> & { external?: boolean } | external: false | A cell-sized link; external opens a new tab, adds rel, and says so out loud. |
| TruncateCell | React.ComponentProps<"span"> & { maxWidth?: number | string } | maxWidth: 240 | Clips long text at a width and keeps the whole string in the title. |
| DeltaCell | { value: number; format?: "percent" | "number" | "compact"; positiveIsGood?: boolean; className?: string } | positiveIsGood: true | A period-over-period change as a MetricDelta, right-aligned under a numeric header. |
Dependencies
Registry
npm
Source
import * as React from "react"
import { CheckIcon, ExternalLinkIcon, MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
clamp,
formatCurrency,
formatDate,
formatNumber,
formatPercent,
formatRelative,
} from "@/lib/format"
import { MetricDelta, type MetricDeltaProps } from "@/components/ui/metric-delta"
// One em dash for every hole in the data, so a missing number never reads as a
// zero and every column lines up on the same placeholder.
const EMPTY = "—"
// The left-to-right names; the alignment itself is logical, so "right" is the
// line's end — the left of a right-to-left table, where its numeric title sits.
type Align = "left" | "right"
export type NumberCellProps = {
value: number | null | undefined
/** Replaces the default thousands-separated formatting. */
format?: (n: number) => string
align?: Align
className?: string
}
// NumberCell and CurrencyCell differ only in how they turn the number into
// text, so they share one body and keep their own data-slot.
function AlignedNumber({
slot,
value,
format = (n: number) => formatNumber(n),
align = "right",
className,
}: NumberCellProps & { slot: string }) {
const empty = value === null || value === undefined
return (
<span
data-slot={slot}
data-align={align}
className={cn(
"block tabular-nums",
align === "right" && "text-end",
empty && "text-muted-foreground",
className
)}
>
{empty ? EMPTY : format(value)}
</span>
)
}
/** A number, at the line's end on tabular figures so digits stack column to column. */
function NumberCell(props: NumberCellProps) {
return <AlignedNumber slot="number-cell" {...props} />
}
export type CurrencyCellProps = {
value: number | null | undefined
/** ISO 4217 code, e.g. "USD", "EUR", "JPY". */
currency?: string
/** Abbreviates large amounts — 1250000 → "$1.3M". */
compact?: boolean
className?: string
}
/** A currency amount, at the line's end like every other number in the table. */
function CurrencyCell({ value, currency = "USD", compact = false, className }: CurrencyCellProps) {
return (
<AlignedNumber
slot="currency-cell"
value={value}
format={(n) => formatCurrency(n, currency, { compact })}
className={className}
/>
)
}
export type PercentCellProps = {
/** A ratio, not a percentage: 0.256 renders as "25.6%". */
value: number | null | undefined
/** Adds a hairline track under the number, filled to the ratio. */
showBar?: boolean
className?: string
}
/** A ratio as a percentage, optionally over a small bar for scanning a column at a glance. */
function PercentCell({ value, showBar = false, className }: PercentCellProps) {
const empty = value === null || value === undefined
return (
<span
data-slot="percent-cell"
className={cn("flex flex-col items-end gap-1 tabular-nums", empty && "text-muted-foreground", className)}
>
<span>{empty ? EMPTY : formatPercent(value)}</span>
{showBar && !empty ? (
<span
data-slot="percent-cell-track"
// Decorative: the percentage right above it already carries the value.
aria-hidden="true"
className="block h-1 w-full min-w-16 overflow-hidden rounded-full bg-muted"
>
<span
data-slot="percent-cell-fill"
className="block h-full rounded-full bg-chart-1"
style={{ width: `${clamp(value * 100, 0, 100)}%` }}
/>
</span>
) : null}
</span>
)
}
export type DateCellProps = {
date: Date | string | number | null | undefined
format?: "short" | "medium" | "long"
/** Reads "3h ago" instead of the date; the exact date stays in the title. */
relative?: boolean
className?: string
}
/** A date, absolute or relative, always with the full date one hover away. */
function DateCell({ date, format = "medium", relative = false, className }: DateCellProps) {
const parsed = date === null || date === undefined ? null : date instanceof Date ? date : new Date(date)
const valid = parsed !== null && !Number.isNaN(parsed.getTime())
if (!valid) {
return (
<span data-slot="date-cell" className={cn("text-muted-foreground", className)}>
{EMPTY}
</span>
)
}
return (
<time
data-slot="date-cell"
data-relative={relative || undefined}
dateTime={parsed.toISOString()}
title={formatDate(parsed, "long")}
// Relative text is read off the clock, and the server's clock is a moment
// behind the browser's.
suppressHydrationWarning={relative}
className={cn("tabular-nums whitespace-nowrap", className)}
>
{relative ? formatRelative(parsed) : formatDate(parsed, format)}
</time>
)
}
export type BooleanCellProps = {
value: boolean | null | undefined
trueLabel?: string
falseLabel?: string
className?: string
}
/** A yes/no value as an icon plus its word, so it never reads by glyph alone. */
function BooleanCell({
value,
trueLabel = "Yes",
falseLabel = "No",
className,
}: BooleanCellProps) {
const state = value === null || value === undefined ? "unknown" : value ? "true" : "false"
return (
<span
data-slot="boolean-cell"
data-value={state}
className={cn(
"inline-flex items-center gap-1.5 whitespace-nowrap [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
state !== "true" && "text-muted-foreground",
className
)}
>
{state === "unknown" ? null : state === "true" ? (
<CheckIcon aria-hidden="true" className="text-success" />
) : (
<MinusIcon aria-hidden="true" />
)}
{state === "unknown" ? EMPTY : state === "true" ? trueLabel : falseLabel}
</span>
)
}
export type LinkCellProps = React.ComponentProps<"a"> & {
/** Opens in a new tab and says so, for anything outside the app. */
external?: boolean
}
/** A link sized for a table cell: underlined on hover, never on rest. */
function LinkCell({ className, external = false, children, ...props }: LinkCellProps) {
return (
<a
data-slot="link-cell"
data-external={external || undefined}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={cn(
"inline-flex items-center gap-1 rounded-sm underline-offset-4 hover:underline focus-ring [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3",
className
)}
{...props}
>
{children}
{external ? (
<>
<ExternalLinkIcon aria-hidden="true" className="text-muted-foreground" />
<span className="sr-only">(opens in a new tab)</span>
</>
) : null}
</a>
)
}
export type TruncateCellProps = React.ComponentProps<"span"> & {
/** A pixel number or any CSS length; the text ellipses past it. */
maxWidth?: number | string
}
/** Long text clipped to a width, with the whole string in the title attribute. */
function TruncateCell({
className,
maxWidth = 240,
style,
title,
children,
...props
}: TruncateCellProps) {
return (
<span
data-slot="truncate-cell"
// Falls back to the text itself, so the full value is always recoverable
// even when the caller did not think to pass a title.
title={title ?? (typeof children === "string" ? children : undefined)}
className={cn("block truncate", className)}
style={{ maxWidth: typeof maxWidth === "number" ? `${maxWidth}px` : maxWidth, ...style }}
{...props}
>
{children}
</span>
)
}
export type DeltaCellProps = {
value: number
format?: MetricDeltaProps["format"]
/** False for metrics where down is the win — churn, latency, cost. */
positiveIsGood?: boolean
className?: string
}
/** A period-over-period change, at the line's end to sit under a numeric header. */
function DeltaCell({ value, format, positiveIsGood, className }: DeltaCellProps) {
return (
<span data-slot="delta-cell" className={cn("flex justify-end", className)}>
<MetricDelta value={value} format={format} positiveIsGood={positiveIsGood} size="sm" />
</span>
)
}
export {
BooleanCell,
CurrencyCell,
DateCell,
DeltaCell,
LinkCell,
NumberCell,
PercentCell,
TruncateCell,
}