DataTable
A data table with sorting, pagination, search, formatting, sticky headers, pinned columns, and a footer row.
import { DataTable } from "metricui";Use DataTable when you need to display tabular data with rich formatting, sorting, pagination, and interactive features. It auto-infers columns from your data shape, supports typed columns (currency, badge, sparkline, progress, and more), multi-column sorting, expandable detail rows, conditional formatting, pinned columns, sticky headers, search, and footer rows. For displaying a single key metric, use KpiCard instead.
Basic Example
Pass just dataand columns are auto-inferred from the first row's keys. Numbers are right-aligned and formatted automatically.
| Name | Revenue | Growth |
|---|---|---|
| Acme Corp | 142.3K | 13 |
| Globex Inc | 98.7K | -3 |
| Initech | 67.4K | 8 |
| Umbrella Corp | 54.2K | 22 |
| Stark Industries | 189K | 16 |
<DataTable
data={[
{ name: "Acme Corp", revenue: 142300, growth: 12.5 },
{ name: "Globex Inc", revenue: 98700, growth: -3.2 },
{ name: "Initech", revenue: 67400, growth: 8.1 },
]}
/>Column Types
The type property on a column definition controls formatting, alignment, and rendering automatically. This is the most powerful feature of DataTable — set a type and the component handles everything else: currency columns get locale-aware formatting, badge columns auto-map status strings to semantic colors, sparkline columns render inline charts, and progress columns show bars. Numeric types (number, currency, percent, bar) use a monospace font for easy scanning; text columns use the body font.
| Type | Renders as | Align | Font |
|---|---|---|---|
"text" | Plain text | Left | Body |
"number" | Locale-formatted number | Right | Mono |
"currency" | Currency-formatted (e.g. $4,999) | Right | Mono |
"percent" | Percent with sign (e.g. +18.3%) | Right | Mono |
"badge" | Semantic Badge component | Left | Body |
"sparkline" | Inline Sparkline chart | Center | N/A |
"status" | StatusIndicator with rules | Left | Body |
"progress" | ProgressBar (0-100) | Center | N/A |
"bar" | Inline horizontal bar | Right | Mono |
"link" | Clickable link with icon | Left | Body |
"date" | Locale-formatted date | Left | Body |
| Product | Price | Growth | Status | Trend (7d) | Adoption | Category |
|---|---|---|---|---|---|---|
| Analytics Pro | $5.0K | 18% | active | SaaS | ||
| Data Vault | $2.5K | -4% | churned | Storage | ||
| Cloud Suite | $7.9K | 13% | active | Platform | ||
| ML Pipeline | $3.2K | 1% | trial | AI/ML | ||
| Edge CDN | $1.3K | 31% | active | Infra |
const columns: ColumnDef<ProductRow>[] = [
{ key: "product", header: "Product", type: "text", sortable: true, pin: "left" },
{ key: "price", header: "Price", type: "currency", sortable: true },
{ key: "growth", header: "Growth", type: "percent", sortable: true },
{ key: "status", header: "Status", type: "badge", sortable: true },
{ key: "trend", header: "Trend (7d)", type: "sparkline" },
{ key: "progress", header: "Adoption", type: "progress" },
{ key: "category", header: "Category", type: "text" },
];
<DataTable
data={productData}
columns={columns}
title="Product Overview"
striped
/>Badge columns automatically map common status strings like "active", "pending", "failed" to semantic colors. For custom mappings, use the badgeVariant or badgeColor column properties. Status columns work with statusRules for rule-based indicator rendering. Link columns use linkHref to generate URLs dynamically.
Sorting & Multi-Sort
Columns with sortable: true cycle through ascending, descending, and none on click. Enable multiSort to allow Shift+click on additional columns for multi-level sorting (up to 3 levels). Active sort columns show numbered priority badges on their headers so users can see the sort hierarchy at a glance.
Shift+click headers to multi-sort
| Rep | Segment | Revenue | Deals |
|---|---|---|---|
| Alice Chen | Enterprise | $285.0K | 12 |
| Bob Park | SMB | $142.0K | 28 |
| Carol Diaz | Enterprise | $310.0K | 8 |
| Dan Kim | SMB | $198.0K | 35 |
| Eva Novak | Enterprise | $275.0K | 15 |
| Frank Li | Mid-Market | $167.0K | 18 |
<DataTable
data={salesReps}
columns={[
{ key: "name", header: "Rep", type: "text", sortable: true },
{ key: "department", header: "Segment", type: "text", sortable: true },
{ key: "revenue", header: "Revenue", type: "currency", sortable: true },
{ key: "deals", header: "Deals", type: "number", sortable: true },
]}
title="Sales Leaderboard"
subtitle="Shift+click headers to multi-sort"
multiSort
/>// Single sort (default) — click toggles asc → desc → none
<DataTable columns={[{ key: "name", sortable: true }]} ... />
// Multi-sort — Shift+click adds secondary/tertiary sort
<DataTable columns={columns} multiSort />
// Up to 3 sort levels. Priority badges (1, 2, 3) appear on headers.Expandable Rows
Pass a renderExpanded function to enable row expansion. Each row gets a chevron toggle on the left that reveals a detail panel below. The function receives the row data and index, and can return any React content.
| Order ID | Customer | Total | Status | Items | |
|---|---|---|---|---|---|
| ORD-001 | Acme Corp | $12.5K | completed | 3 | |
| ORD-002 | Globex Inc | $8.9K | pending | 2 | |
| ORD-003 | Initech | $23.1K | completed | 5 | |
| ORD-004 | Umbrella Corp | $4.5K | failed | 1 |
<DataTable
data={orders}
columns={orderColumns}
title="Recent Orders"
renderExpanded={(row) => (
<div className="space-y-2 text-sm text-[var(--muted)]">
<p><strong>Order:</strong> {row.id}</p>
<p><strong>Date:</strong> {row.date}</p>
<p><strong>Items:</strong> {row.items} line items</p>
<p><strong>Notes:</strong> Click any row to see its details.</p>
</div>
)}
/>Conditional Formatting
Apply color rules at the column level with conditions — the same system used by KpiCard. Rules are evaluated top-to-bottom; first match wins. Supports above, below, between, equals, and compound and/or rules. For row-level highlighting, use rowConditions to apply CSS classes to entire rows based on a predicate function.
| Metric | Current | Target | Delta |
|---|---|---|---|
| Uptime | 100 | 100 | 0 |
| Latency (ms) | 145 | 100 | 45 |
| Error Rate | 0 | 1 | -1 |
| Throughput | 8.5K | 10K | -1.5K |
| Satisfaction | 5 | 5 | 0 |
const columns = [
{ key: "metric", header: "Metric", type: "text" },
{
key: "value",
header: "Current",
type: "number",
conditions: [
{ when: "above", value: 99, color: "emerald" },
{ when: "between", min: 50, max: 99, color: "amber" },
{ when: "below", value: 50, color: "red" },
],
},
{ key: "target", header: "Target", type: "number" },
{
key: "delta",
header: "Delta",
type: "number",
conditions: [
{ when: "above", value: 0, color: "emerald" },
{ when: "below", value: 0, color: "red" },
],
},
];
<DataTable data={metrics} columns={columns} title="SLA Dashboard" />// Row-level conditions — apply a className to entire rows
<DataTable
data={data}
columns={columns}
rowConditions={[
{
when: (row) => row.status === "critical",
className: "bg-red-500/10",
},
{
when: (row) => row.status === "warning",
className: "bg-amber-500/10",
},
]}
/>Data States
DataTable handles loading, empty, and error states out of the box. The loading state renders animated skeleton rows that match your column layout.
Loading
Nothing to show — try adjusting your filters
Empty
No data matches your filters
// Loading — renders skeleton rows
<DataTable data={[]} loading />
// Empty state with custom message
<DataTable data={[]} empty={{ message: "No results found" }} />
// Error state
<DataTable data={[]} error={{ message: "Failed to load data" }} />
// Stale data indicator
<DataTable data={staleData} stale={{ message: "Data is 5 minutes old" }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
data* | T[] | — | Row data array. |
columns | Column<T>[] | — | Column definitions. When omitted, columns are auto-inferred from the first row. |
title | string | — | Card title. |
subtitle | string | — | Card subtitle. |
description | string | React.ReactNode | — | Description popover content. |
footnote | string | — | Footnote below the table. |
action | React.ReactNode | — | Action slot (top-right). |
pageSize | number | — | Page size for pagination. Set to enable pagination. |
pagination | boolean | — | Enable pagination. Default: true when pageSize is set. |
maxRows | number | — | Max visible rows. Shows "View all" when exceeded. |
onViewAll | () => void | — | Callback when "View all" is clicked. |
striped | boolean | false | Alternating row backgrounds. |
dense | boolean | — | Compact row height. Falls back to config.dense. |
onRowClick | (row: T, index: number) => void | — | Row click handler. |
nullDisplay | NullDisplay | "dash" | What to show when a cell value is null/undefined. |
footer | FooterRow | — | Summary/totals footer row. Object keyed by column key with ReactNode values. |
variant | CardVariant | — | Visual variant (supports custom strings). CSS-variable-driven via [data-variant]. |
className | string | — | Additional CSS class names. |
loading | boolean | — | Show skeleton rows. |
empty | EmptyState | — | Empty state configuration. |
error | ErrorState | — | Error state. |
stale | StaleState | — | Stale data indicator. |
id | string | — | HTML id attribute. |
data-testid | string | — | Test id. |
stickyHeader | boolean | — | Sticky table header on scroll. |
classNames | { root?: string; header?: string; table?: string; thead?: string; tbody?: string; row?: string; cell?: string; footer?: string; pagination?: string } | — | Sub-element class name overrides. |
searchable | boolean | — | Show a search input above the table for client-side filtering. |
animate | boolean | true | Enable/disable animations. Currently reserved for future use. |
scrollIndicators | boolean | — | Show left/right fade indicators when the table is horizontally scrollable. |
rowConditions | RowCondition<T>[] | — | Conditional row styling. Each entry has a `when(row, index) => boolean` predicate and a `className` to apply when true. |
multiSort | boolean | false | Enable Shift+click multi-column sorting. Default: false (single sort). |
renderExpanded | (row: T, index: number) => React.ReactNode | — | Render expanded detail panel below a row. Enables chevron toggle on each row. |
crossFilter | boolean | { field: string } | — | Enable cross-filter signal on row click. true uses the first column key, { field } overrides. Emits selection via CrossFilterProvider — does NOT change table appearance. Dev reads selection with useCrossFilter() and filters their own data. |
drillDown | boolean | ((event: { row: T; index: number }) => React.ReactNode) | — | Enable drill-down on row click. `true` auto-generates a summary KPI row + detail view from the row data. Pass a render function for full control over the panel content. Requires DrillDown.Root wrapper. When both drillDown and crossFilter are set, drillDown wins. |
drillDownMode | DrillDownMode | "slide-over" | Presentation mode for the drill-down panel. "slide-over" (default) slides from the right, full height. "modal" renders centered and compact. |
Data Shape
type ColumnType = "text" | "number" | "currency" | "percent" | "link" | "badge" | "sparkline" | "status" | "progress" | "date" | "bar";
interface Column<T> {
key: string; // Property key on the data object
header?: string; // Column header text (preferred)
label?: string; // @deprecated — use header
type?: ColumnType; // Column type — auto-formats and auto-aligns. See ColumnType docs.
format?: FormatOption; // Auto-format numeric values (overrides type-inferred format)
align?: "left" | "center" | "right"; // Default: left for text, right for numbers, center for sparkline/progress
width?: string | number; // Column width (CSS value)
sortable?: boolean; // Enable sorting
render?: (value: any, row: T, index: number) => React.ReactNode; // Custom cell renderer
pin?: "left"; // Sticky column on horizontal scroll
wrap?: boolean; // Allow text wrapping in cells (default: truncate)
conditions?: Condition[]; // Conditional cell coloring based on value
linkHref?: (value: any, row: T) => string; // URL resolver for type:"link" columns
linkTarget?: string; // Link target attribute (e.g. "_blank")
badgeColor?: (value: any, row: T) => string | undefined; // Custom badge color for type:"badge"
badgeVariant?: (value: any, row: T) => BadgeVariant | undefined; // Custom badge variant for type:"badge"
statusRules?: StatusRule[]; // Threshold rules for type:"status" columns
statusSize?: StatusSize; // Display size for type:"status" columns
dateFormat?: Intl.DateTimeFormatOptions; // Intl format options for type:"date" columns
}
interface RowCondition<T> {
when: (row: T, index: number) => boolean; // Predicate — return true to apply className
className: string; // CSS class to apply to matching rows
}
interface FooterRow {
[key: string]: React.ReactNode;
}Notes
- Generic component — TypeScript infers T from the data prop.
- When columns are omitted, they are auto-inferred from the first row's keys with camelCase-to-Title-Case conversion.
- Sort cycles through: asc -> desc -> none. With multiSort, Shift+click adds secondary/tertiary sorts.
- Column `header` is preferred over `label` (which is deprecated).
- Auto-alignment: numeric types (number, currency, percent, bar) right-align. sparkline/progress center-align. Everything else left-aligns.
- Column types auto-render: 'currency'/'percent'/'number' auto-format values, 'badge' renders a Badge, 'status' renders StatusIndicator, 'sparkline' renders a Sparkline, 'link' renders a clickable link, 'progress' renders a ProgressBar, 'date' formats via Intl.DateTimeFormat, 'bar' renders inline bar chart.
- The pagination component shows 'Previous' / 'Next' buttons with row range indicator.
- Search filters across all column values using case-insensitive string matching.
- renderExpanded adds a chevron toggle column. Clicking a row expands/collapses the detail panel.
- rowConditions apply CSS classes to rows matching predicates — useful for highlighting warnings or critical rows.
- crossFilter prop emits a selection signal on row click — it does NOT change the table's appearance. The dev reads the selection via useCrossFilter() and filters their own data.
- drillDown={true} auto-generates a summary KPI row + detail view from the clicked row. Pass a render function for custom panel content. Requires DrillDown.Root wrapper.
- When both drillDown and crossFilter are set on the same component, drillDown wins.
Playground
Experiment with every prop interactively. Adjust the controls on the right to see the component update in real time.
Live Preview
By revenue this quarter
| Product | Revenue | Units Sold | Growth | Status |
|---|---|---|---|---|
| Pro Plan | $128.4K | 856 | +24.5% | active |
| Enterprise Plan | $96.2K | 124 | +18.2% | active |
| Starter Plan | $45.8K | 1.8K | -3.1% | active |
| API Add-on | $34.6K | 412 | +42.8% | active |
| Storage Add-on | $22.1K | 678 | +15.4% | active |
| Analytics Add-on | $18.9K | 345 | +8.7% | new |
| Support Premium | $15.4K | 189 | -5.2% | active |
| Migration Service | $12.8K | 64 | +31% | new |
| Training Package | $9.6K | 48 | -12.4% | sunset |
| Legacy Plan | $4.2K | 210 | -28.6% | sunset |
| Consulting Hours | $3.8K | 19 | +5% | active |
| Custom Integration | $2.4K | 8 | +100% | new |
Code
<DataTable
data={topProducts}
columns={[
{ key: "product", header: "Product", sortable: true, pin: "left" },
{ key: "revenue", header: "Revenue", sortable: true, format: "currency" },
{ key: "units", header: "Units Sold", sortable: true, format: "number" },
{ key: "growth", header: "Growth", sortable: true, align: "right", render: (v, row) => <...> },
{ key: "status", header: "Status", render: (v, row) => <...> },
]}
title="Top Products"
subtitle="By revenue this quarter"
/>Props
Adjust props to see the table update in real time
Switch between sample datasets
0 = show all. Otherwise shows "View all" link.