People need to scan, sort, and compare exact values for many items.
Updated September 10, 2026
Problem
People need exact values for many items and a way to sort them by the measure relevant to their task.
Solution
Show items in rows with clearly labeled columns and units. Let people sort and filter, and make the active sort order visible.
Use small bars, status labels, or sparklines when they make a column easier to scan. Keep exact values available and avoid adding so many visual treatments that the table becomes harder to read.
State whether sorting and filtering cover all records or only the loaded subset. Show limits and pagination clearly. Keep essential columns accessible on smaller screens.
Use when
Exact values and item-level actions matter, or people need to choose their own ordering.
Don’t use when
The question is about trend or shape. A table of 24 hourly values is a line chart that has been made harder to read. Also avoid it as the only view when most rows are uninteresting; rank and cut instead.
Trade-offs
Large tables take effort to scan and fit poorly on narrow or distant screens. Sorting can make small, unimportant differences look meaningful. Too many visual cell styles add clutter rather than clarity.
Checklist
What is the default sort, and is it the order most viewers need first?
Is the sort applied to the whole population or only to the rows the query returned?
Are numbers right-aligned with tabular figures so columns compare by eye?
Do units and precision match how the team talks, and are they consistent down the column?
How many rows before this needs pagination, and does pagination break the sort?
Which columns survive a narrow viewport, and who decided?
Is there a row action, and does the whole row afford it or just one cell?
Do colour-coded cells derive from the same thresholds as the charts nearby?
What does a null show as, and can it be told apart from a zero?
Would two of these columns be better as one chart?
Compare
Grafana turns the cell into a container—background colour, in-cell bar gauge, in-cell sparkline, data links—so a single table can carry status, magnitude and trend per row and needs discipline to stay readable. Sentry builds its primary interface as a table and treats each row as an object with its own lifecycle: count, users affected, first seen, last seen, and a shape, which is the pattern doing triage rather than reporting. Honeycomb returns a results table as one output of a query rather than a standing panel, so the columns are whatever you grouped by a moment ago instead of a schema someone fixed in advance. Netdata uses tables mainly for inventory—nodes, alarms, collectors—and leaves the measurement to its charts, which keeps both simple.
Related
Ranked list shows a selected set in order. Drill-down opens details for a row. Sparkline adds a compact trend.
Filter bar narrows the records. Two-pane list and detail keeps a selected record beside the list.
Wireframe — the pattern's anatomy, not any one product's version of it
Implementation
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.
DataTable.tsxColumns declare a cell mode from a closed set, header clicks cycle the sort, and the footer computes what fraction of the population it sorted.
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<Row> = { 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<Row> = { key: keyof Row & string; dir: "desc" | "asc" } | null;export function DataTable<Row extends Record<string, unknown>>({ columns, rows, total, defaultSort = null, onSort, onRowClick }: { columns: Column<Row>[]; /** 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<Row>; onSort?: (sort: Sort<Row>) => void; onRowClick?: (row: Row) => void;}) { const [sort, setSort] = useState<Sort<Row>>(defaultSort); const cycle = (key: keyof Row & string) => { const next: Sort<Row> = 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: 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 <span className={cn("rounded-[2px] px-2 py-0.5", STEP[Math.min(step, STEP.length - 1)])}>{text}</span>; } case "bar": return ( <span role="meter" aria-valuemin={0} aria-valuemax={1} aria-valuenow={v as number} aria-label={`${col.label} ${text}`} title={text} className="flex h-2.5 w-[110px] rounded-[2px] bg-muted"> <span className="rounded-[2px] bg-chart-1" style={{ width: `${(v as number) * 100}%` }} /> </span> ); case "sparkline": return <span className="text-muted-foreground"><Sparkline values={v as number[]} width={96} height={20} /></span>; case "link": return <a href={col.href?.(row)} className="text-chart-1 underline underline-offset-2">{text}</a>; default: return text; } }; return ( <div className="overflow-hidden rounded-lg border bg-card"> <Table className="text-xs"> <TableHeader> <TableRow className="text-[11px] uppercase tracking-wide"> {columns.map((c) => ( <TableHead key={c.key} className={cn("h-9 px-4", c.align === "right" && "text-right")}> <button type="button" onClick={() => cycle(c.key)} className="font-medium" aria-sort={sort?.key === c.key ? (sort.dir === "desc" ? "descending" : "ascending") : undefined}> {c.label} {sort?.key === c.key && <span className="ml-1 text-foreground" aria-hidden="true">{sort.dir === "desc" ? "▾" : "▴"}</span>} </button> </TableHead> ))} </TableRow> </TableHeader> <TableBody className="tabular-nums"> {sorted.map((row, i) => ( <TableRow key={i} onClick={onRowClick && (() => onRowClick(row))} className={cn(onRowClick && "cursor-pointer")}> {columns.map((c) => ( <TableCell key={c.key} className={cn("px-4 py-2.5 text-card-foreground", c.align === "right" && "text-right")}>{cell(c, row)}</TableCell> ))} </TableRow> ))} </TableBody> </Table> {/* Sorting a sample looks exactly like sorting the data, so the footer says which it was. */} {rows.length < total && ( <p className="border-t px-4 py-2.5 text-[11px] text-status-warn"> showing {rows.length} of {total.toLocaleString("en-GB")} rows · sorted client-side </p> )} </div> );}
demo.tsxHow it is called: four services of 9,412, sorted by error rate, with a coloured cell, a bar, a sparkline and a link in each row.
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<Service>[] = [ { 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 ( <DataTable columns={COLUMNS} rows={ROWS} total={9412} defaultSort={{ key: "errorRate", dir: "desc" }} onSort={(s) => console.log("sort", s)} /> );}
What it renders. Identical markup in both panes, with only the token values changing.
Screenshots are grouped by product and include the capture date.
Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.
Where The Cool Dashboards Live / Chicago L Trains September 10, 2026Grafana Play (signed out; no version string exposed)sparse · dark · wall
The departure board gives the next train more space than later arrivals and uses large text without hover-only details. Route colors connect the display to the train lines people already know. Green also appears in the On Time status, so the same hue has two meanings in one row. The status label helps distinguish them. Countdown values are useful, but the board also needs a clear indication of feed age.
Wallboard modeThe next departure receives the largest text and panel.
Panel gridLater departures use smaller rows, making their lower priority visible.
Semantic status colorGreen identifies both a route and an on-time status; the words distinguish the meanings.
Freshness indicatorA countdown shows time until arrival but does not state the feed’s age.
Data tableThe raw feed includes the color value for each route.
Examples / Dashboard Variables September 10, 2026Grafana Play (signed out; no version string exposed)medium · dark · desktop-web
This dashboard demonstrates six selectors and explains how their values affect the page. Selected values appear as removable chips, making the active scope easy to read. A text panel also describes a hidden variable that has no visible control. Another note explains that changing the data source clears an instance selection. These dependencies need clear feedback when people change a value.
Template variableSelectors provide values used by panel queries and shared in the URL.
Filter barSelected values appear as removable chips.
Explain this metricA text panel explains a hidden variable and its current value.
Examples / Table September 10, 2026Grafana Play (signed out; no version string exposed)dense · dark · desktop-web
The tables demonstrate colored cells, gradients, images, bars, pills, and richer text. Some treatments become less useful in narrow columns: a bar is squeezed into a thin strip and nearby text is cut off. Another table shows many pages of results alongside sort controls. The interface needs to make clear whether sorting applies to all returned records or a smaller subset.
Data tableThreshold colors help people scan the column for states.
Dashboards / list September 10, 2026Elastic demo environment (guest session)medium · light · desktop-web
The dashboard list has fourteen pages, with many similarly prefixed Kubernetes entries on the first page. Search helps people find a specific destination without browsing the full collection. Descriptions beneath titles provide useful context, while prefixes and tags group related entries. The list would also benefit from a clear indication of which dashboards are maintained or recommended.
Discover / filebeat logs September 10, 2026Elastic demo environment (guest session; no version string exposed)dense · light · desktop-web
The view places a searchable field list beside a large table of documents, with a volume chart above. Search is useful for navigating the hundreds of available fields. The default Summary column combines many fields into each row, making quick comparisons difficult until people choose more focused columns. Pagination and sorting are visible, but the scope of each operation needs to be clear.