Data table shadcn's Table gives you the markup. What a dashboard table needs beyond it is cells that carry more than text, meaning a bar, a status or a sparkline, and a footer that admits the sort ran over a sample. npx shadcn@latest add table Tokens this needs: --background, --foreground, --card, --card-foreground, --muted, --muted-foreground, --border, --status-warn, --chart-1, --scale-seq-1, --scale-seq-3, --scale-seq-5 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // DataTable.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { cn } from "@/lib/utils"; import { Sparkline } from "../sparkline/Sparkline"; /** * What goes inside a cell is the whole difference between a dashboard table * and a spreadsheet. The modes are a closed set, after Grafana's cell display * modes: colour the cell by threshold so the column scans, draw the value as * a bar so magnitude and figure share a column, put a sparkline in the cell, * or make the value a link out. */ export type CellMode = "text" | "background" | "bar" | "sparkline" | "link"; export type Column = { key: keyof Row & string; label: string; mode?: CellMode; align?: "left" | "right"; format?: (value: Row[keyof Row]) => string; /** Lower bound of each colour step for `background`, ascending. Share these with the charts beside the table. */ thresholds?: number[]; href?: (row: Row) => string; }; /** Threshold steps for `background`, low to high. Full class names so Tailwind ships them. */ const STEP = ["bg-scale-seq-1 text-foreground", "bg-scale-seq-3 text-foreground", "bg-scale-seq-5 text-background"]; type Sort = { key: keyof Row & string; dir: "desc" | "asc" } | null; export function DataTable>({ columns, rows, total, defaultSort = null, onSort, onRowClick }: { columns: Column[]; /** What the query returned. The sort runs over these and nothing else. */ rows: Row[]; /** How many rows the population holds. Required: without it a sorted sample looks exactly like sorted data. */ total: number; defaultSort?: Sort; onSort?: (sort: Sort) => void; onRowClick?: (row: Row) => void; }) { const [sort, setSort] = useState>(defaultSort); const cycle = (key: keyof Row & string) => { const next: Sort = sort?.key !== key ? { key, dir: "desc" } : sort.dir === "desc" ? { key, dir: "asc" } : null; setSort(next); onSort?.(next); }; const sorted = sort ? [...rows].sort((a, b) => { const x = a[sort.key], y = b[sort.key]; const d = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y)); return sort.dir === "desc" ? -d : d; }) : rows; const cell = (col: Column, row: Row) => { const v = row[col.key]; const text = col.format ? col.format(v) : String(v); switch (col.mode) { case "background": { const step = (col.thresholds ?? []).filter((t) => (v as number) >= t).length; return {text}; } case "bar": return ( ); case "sparkline": return ; case "link": return {text}; default: return text; } }; return (
{columns.map((c) => ( ))} {sorted.map((row, i) => ( onRowClick(row))} className={cn(onRowClick && "cursor-pointer")}> {columns.map((c) => ( {cell(c, row)} ))} ))}
{/* Sorting a sample looks exactly like sorting the data, so the footer says which it was. */} {rows.length < total && (

showing {rows.length} of {total.toLocaleString("en-GB")} rows · sorted client-side

)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { DataTable, type Column } from "./DataTable"; /** * Four services out of a population of 9,412, sorted by error rate on the * client. Error rate colours its cell against the thresholds the charts use, * budget left is a bar, the last hour is a sparkline, and P95 links out. */ type Service = { service: string; errorRate: number; budgetLeft: number; lastHour: number[]; p95: number }; const COLUMNS: Column[] = [ { key: "service", label: "Service" }, { key: "errorRate", label: "Error rate", mode: "background", thresholds: [1, 3], format: (v) => `${(v as number).toFixed(2)}%` }, { key: "budgetLeft", label: "Budget left", mode: "bar", format: (v) => `${Math.round((v as number) * 100)}% left` }, { key: "lastHour", label: "Last hour", mode: "sparkline" }, { key: "p95", label: "P95", mode: "link", align: "right", format: (v) => `${v}ms`, href: (r) => `/services/${r.service}/latency` }, ]; const ROWS: Service[] = [ { service: "checkout", errorRate: 4.12, budgetLeft: 0.2, lastHour: [4, 8, 6, 16, 20], p95: 412 }, { service: "search", errorRate: 1.8, budgetLeft: 0.58, lastHour: [2, 4, 0, 6, 4], p95: 308 }, { service: "cart", errorRate: 0.41, budgetLeft: 0.84, lastHour: [2, 4, 0, 6, 4], p95: 191 }, { service: "user", errorRate: 0.12, budgetLeft: 0.95, lastHour: [2, 4, 0, 6, 4], p95: 96 }, ]; export default function Demo() { return ( console.log("sort", s)} /> ); }