Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Data table

‹ 5 of 22 ›

Data table

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.

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.

Data table anatomy A table whose cells carry more than text: a coloured background column scannable at a glance, a bar rendered inside the cell, a sparkline in the cell, and a linked value. The sort control sits on the header, and the footer says the sort ran over a hundred rows out of nine thousand. What goes inside a cell Service Error rate Budget left Last hour P95 checkout 4.12% 412ms search 1.80% 308ms cart 0.41% 191ms user 0.12% 96ms showing 100 of 9,412 rows · sorted client-side 1 2 3 4 1 COLOURED BACKGROUND Threshold colour on the whole cell, so the column is scannable without being read. 2 A BAR IN THE CELL Magnitude and exact value in one column, which no chart beside the table can give. 3 A SPARKLINE IN THE CELL A small-multiples grid that happens to have labels and exact numbers attached, which beats nine separate panels. 4 SORTING A SAMPLE Sorting happens on what the query returned, not on the population, and it looks exactly like sorting the data. The superpower isn't display, it's reordering. Two sorts answer two questions.
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.

shadcn
npx shadcn@latest add table
Tokens
--background--foreground--card--card-foreground--muted--muted-foreground--border--status-warn--chart-1--scale-seq-1--scale-seq-3--scale-seq-5
checkout4.12%412ms
search1.80%308ms
cart0.41%191ms
user0.12%96ms

showing 4 of 9,412 rows · sorted client-side

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.

Examples

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.

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Where The Cool Dashboards Live / Chicago L Trains September 10, 2026 Grafana 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 mode The next departure receives the largest text and panel.
  • Panel grid Later departures use smaller rows, making their lower priority visible.
  • Semantic status color Green identifies both a route and an on-time status; the words distinguish the meanings.
  • Freshness indicator A countdown shows time until arrival but does not state the feed’s age.
  • Data table The raw feed includes the color value for each route.
Show 4 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Dashboard Variables September 10, 2026 Grafana 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.
Examples / Table September 10, 2026 Grafana 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.

Kibana

A query bar and filtering controls define the data shown in charts and document tables.

Dashboards / list September 10, 2026 Elastic 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, 2026 Elastic 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.