Skip to contentVibraUI
Data display

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-cells

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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: falseA 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.
LinkCellReact.ComponentProps<"a"> & { external?: boolean }external: falseA cell-sized link; external opens a new tab, adds rel, and says so out loud.
TruncateCellReact.ComponentProps<"span"> & { maxWidth?: number | string }maxWidth: 240Clips 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: trueA period-over-period change as a MetricDelta, right-aligned under a numeric header.

Dependencies

Source

components/ui/table-cells.tsx
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,
}