# Data table

> A table of rows with named saved views and live counts, row selection, an action slot that receives the selected rows, and stale, empty, no-match and error states.

```tsx
"use client"

import * as React from "react"
import { CircleCheck, Clock, TriangleAlert } from "lucide-react"

import { ConfirmSend } from "@/components/ui/confirm-send"
import {
  DataTable,
  type DataTableColumn,
  type DataTableView,
} from "@/components/ui/data-table"
import { StatusStrip } from "@/components/ui/status-strip"
import { Switch } from "@/components/ui/switch"

interface Gauge {
  id: string
  name: string
  owner: string
  /** Where the reminder would go. Email only. */
  ownerEmail: string
  /** Calibration due date, YYYY-MM-DD. */
  due: string
}

type Calibration = "overdue" | "due-soon" | "in-date"

// Fictional data. A fixed "today" keeps the demo the same whenever it is
// opened. Nothing here is saved or sent anywhere.
const TODAY = "2026-10-01"
const DAY = 86_400_000
const daysUntil = (due: string) =>
  Math.round((Date.parse(due) - Date.parse(TODAY)) / DAY)
const calibration = (gauge: Gauge): Calibration => {
  const days = daysUntil(gauge.due)
  return days < 0 ? "overdue" : days <= 30 ? "due-soon" : "in-date"
}
const RANK: Record<Calibration, number> = {
  overdue: 0,
  "due-soon": 1,
  "in-date": 2,
}

const OWNERS = {
  mara: { owner: "Mara Okafor", ownerEmail: "mara@example.org" },
  jules: { owner: "Jules Bernard", ownerEmail: "jules@example.org" },
  ines: { owner: "Ines Varga", ownerEmail: "ines@example.org" },
  tomas: { owner: "Tomas Lindqvist", ownerEmail: "tomas@example.org" },
}

const GAUGES: Gauge[] = [
  {
    id: "G-101",
    name: "Micrometer 0-25 mm",
    ...OWNERS.mara,
    due: "2026-09-02",
  },
  {
    id: "G-104",
    name: "Dial caliper 150 mm",
    ...OWNERS.mara,
    due: "2026-09-18",
  },
  {
    id: "G-107",
    name: "Torque wrench 20-100 Nm",
    ...OWNERS.jules,
    due: "2026-08-21",
  },
  {
    id: "G-112",
    name: "Pressure gauge 0-10 bar",
    ...OWNERS.tomas,
    due: "2026-09-27",
  },
  {
    id: "G-115",
    name: "Height gauge 300 mm",
    ...OWNERS.ines,
    due: "2026-07-30",
  },
  { id: "G-118", name: "Thread plug M8", ...OWNERS.jules, due: "2026-09-09" },
  {
    id: "G-121",
    name: "Bore gauge 18-35 mm",
    ...OWNERS.ines,
    due: "2026-09-29",
  },
  {
    id: "G-124",
    name: "Surface plate 400 mm",
    ...OWNERS.tomas,
    due: "2026-10-09",
  },
  {
    id: "G-127",
    name: "Dial indicator 0.01 mm",
    ...OWNERS.mara,
    due: "2026-10-14",
  },
  {
    id: "G-130",
    name: "Torque screwdriver 1-6 Nm",
    ...OWNERS.jules,
    due: "2026-10-22",
  },
  {
    id: "G-133",
    name: "Digital caliper 200 mm",
    ...OWNERS.ines,
    due: "2026-10-27",
  },
  { id: "G-136", name: "Feeler gauge set", ...OWNERS.tomas, due: "2026-10-30" },
  {
    id: "G-139",
    name: "Micrometer 25-50 mm",
    ...OWNERS.mara,
    due: "2026-12-04",
  },
  {
    id: "G-142",
    name: "Pin gauge set 1-10 mm",
    ...OWNERS.jules,
    due: "2026-12-18",
  },
  { id: "G-145", name: "Gauge block set", ...OWNERS.ines, due: "2027-01-12" },
  {
    id: "G-148",
    name: "Pressure gauge 0-6 bar",
    ...OWNERS.tomas,
    due: "2027-02-03",
  },
  { id: "G-151", name: "Thermometer probe", ...OWNERS.mara, due: "2027-02-25" },
  {
    id: "G-154",
    name: "Torque wrench 5-25 Nm",
    ...OWNERS.jules,
    due: "2027-03-16",
  },
]

const formatDate = (iso: string) =>
  new Date(iso).toLocaleDateString("en-GB", {
    day: "numeric",
    month: "short",
    year: "numeric",
    timeZone: "UTC",
  })

const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`

// The status is an icon and words, so it still reads without colour.
function StatusCell({ gauge }: { gauge: Gauge }) {
  const days = daysUntil(gauge.due)
  const status = calibration(gauge)
  if (status === "overdue") {
    return (
      <span className="inline-flex items-center gap-1.5 font-medium text-destructive">
        <TriangleAlert className="size-4" aria-hidden="true" />
        Overdue by {plural(-days, "day")}
      </span>
    )
  }
  if (status === "due-soon") {
    return (
      <span className="inline-flex items-center gap-1.5">
        <Clock className="size-4" aria-hidden="true" />
        Due in {plural(days, "day")}
      </span>
    )
  }
  return (
    <span className="inline-flex items-center gap-1.5 text-muted-foreground">
      <CircleCheck className="size-4" aria-hidden="true" />
      In date
    </span>
  )
}

const columns: DataTableColumn<Gauge>[] = [
  {
    key: "gauge",
    header: "Gauge",
    rowHeader: true,
    sortValue: (g) => g.id,
    cell: (g) => (
      <span className="flex flex-col">
        <span>{g.id}</span>
        <span className="text-xs font-normal text-muted-foreground">
          {g.name}
        </span>
      </span>
    ),
  },
  {
    key: "owner",
    header: "Owner",
    sortValue: (g) => g.owner,
    cell: (g) => g.owner,
  },
  {
    key: "due",
    header: "Due date",
    sortValue: (g) => g.due,
    cell: (g) => formatDate(g.due),
  },
  {
    key: "status",
    header: "Status",
    sortValue: (g) => RANK[calibration(g)],
    cell: (g) => <StatusCell gauge={g} />,
  },
]

const views: DataTableView<Gauge>[] = [
  { key: "all", label: "All", filter: () => true },
  {
    key: "overdue",
    label: "Overdue",
    filter: (g) => calibration(g) === "overdue",
  },
  {
    key: "due-soon",
    label: "Due in 30 days",
    filter: (g) => calibration(g) === "due-soon",
  },
]

export function DataTableDemo() {
  const [selectedIds, setSelectedIds] = React.useState<string[]>([])
  const [sending, setSending] = React.useState(false)
  const [sentCount, setSentCount] = React.useState<number | null>(null)
  const [oldData, setOldData] = React.useState(false)
  const [failed, setFailed] = React.useState(false)
  const [noRows, setNoRows] = React.useState(false)
  // Captured once, so the "As of" time is the moment the demo opened.
  const [openedAt] = React.useState(() => Date.now())

  const rows = noRows ? [] : GAUGES
  const count = (status: Calibration) =>
    rows.filter((g) => calibration(g) === status).length

  return (
    <div className="flex w-full max-w-3xl flex-col gap-6">
      <StatusStrip
        headlineNoun="gauges in calibration"
        segments={[
          {
            key: "in-date",
            label: "in date",
            count: count("in-date"),
            tone: "done",
          },
          {
            key: "due-soon",
            label: "due in 30 days",
            count: count("due-soon"),
            tone: "active",
          },
          {
            key: "overdue",
            label: "overdue",
            count: count("overdue"),
            tone: "pending",
          },
        ]}
      />
      <DataTable
        caption="Gauges and when each is next due for calibration"
        noun="gauge"
        rows={rows}
        columns={columns}
        views={views}
        defaultActiveView="overdue"
        getRowId={(g) => g.id}
        rowLabel={(g) => `${g.id}, ${g.name}`}
        defaultSort={{ key: "due", direction: "asc" }}
        selectedIds={selectedIds}
        onSelectionChange={(ids) => {
          setSelectedIds(ids)
          setSentCount(null)
        }}
        asOf={openedAt - (oldData ? 3 * 60 * 60_000 : 5 * 60_000)}
        onRefresh={() => setOldData(false)}
        error={failed}
        onRetry={() => setFailed(false)}
        actions={(selected) => {
          const owners = new Set(selected.map((g) => g.ownerEmail))
          return (
            <ConfirmSend
              count={owners.size}
              noun="owner"
              label="Email a reminder"
              emptyReason="Select at least one gauge first"
              blockedReason={
                oldData ? "Refresh the list before sending" : undefined
              }
              sending={sending}
              sentCount={sentCount}
              onSend={async () => {
                setSending(true)
                await new Promise((r) => setTimeout(r, 600))
                setSending(false)
                setSentCount(owners.size)
              }}
            />
          )
        }}
      />
      <div className="flex flex-col gap-2 text-sm text-muted-foreground">
        <label className="flex items-center gap-2">
          <Switch checked={oldData} onCheckedChange={setOldData} />
          Pretend the list is 3 hours old
        </label>
        <label className="flex items-center gap-2">
          <Switch checked={failed} onCheckedChange={setFailed} />
          Pretend the list failed to load
        </label>
        <label className="flex items-center gap-2">
          <Switch checked={noRows} onCheckedChange={setNoRows} />
          Pretend there are no gauges
        </label>
      </div>
    </div>
  )
}
```

Use it when someone needs to see a list of people, assets or records with a status, narrow it to the exceptions, and then act on those: calibrations that are overdue, staff who have not finished training, students absent today. It sits under a [`status-strip`](https://realgood.site/docs/components/status-strip.md), which says "6 of 18 gauges in calibration". The table lists the 12 that are not, and its `actions` slot is where [`confirm-send`](https://realgood.site/docs/components/confirm-send.md) goes.

It reads left to right and top to bottom: **which view → how many → which rows → what to do with them.**

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/data-table.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/data-table
```

This also adds `table-view` (the pure helpers), the shadcn `button`, `checkbox` and `table`, and `lucide-react`.

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/data-table.tsx"
"use client"

import * as React from "react"
import {
  ArrowDown,
  ArrowUp,
  ChevronsUpDown,
  Clock,
  TriangleAlert,
} from "lucide-react"

import { cn } from "@/lib/utils"
import {
  clearSelection,
  countViews,
  deselectAllVisible,
  filterByView,
  findView,
  formatShown,
  isStale,
  nextSort,
  pruneSelection,
  sameSelection,
  selectAllVisible,
  selectedRows,
  selectionState,
  sortRows,
  toggleId,
  uniqueById,
  type SortState,
  type SortValue,
  type TableView,
} from "@/lib/table-view"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

/** A named preset. Same shape as `TableView` in `table-view`. */
export type DataTableView<T> = TableView<T>

export interface DataTableColumn<T> {
  /** React key, and the key `defaultSort` and `onSortChange` use. Unique. */
  key: string
  /** Column heading. Also named in the "Sorted by …" announcement. */
  header: string
  /** What the cell shows. Pair a colour with text or an icon, never colour alone. */
  cell: (row: T) => React.ReactNode
  /** Makes the column sortable. Null, undefined and NaN sort last. */
  sortValue?: (row: T) => SortValue
  /** Default "left". Use "right" for numbers. */
  align?: "left" | "right"
  /** Renders this column's cells as row headers. Defaults to the first column. */
  rowHeader?: boolean
  className?: string
}

export interface DataTableProps<T>
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Every row, before any view is applied. A repeated id keeps its first row. */
  rows: T[]
  columns: DataTableColumn<T>[]
  /** A stable, unique id for a row. Selection is kept by id. */
  getRowId: (row: T) => string
  /** What a person calls this row, e.g. "Gauge G-104". Names its checkbox. */
  rowLabel: (row: T) => string
  /** Names the table for screen readers. Not shown. */
  caption: string
  /** One row, e.g. "gauge". Default "row". */
  noun?: string
  /** Plural of `noun`. Default `noun + "s"`. */
  nounPlural?: string
  /** Named presets, one active at a time. Each button shows its live count. */
  views?: DataTableView<T>[]
  /** Controlled: the key of the active view. Unknown keys fall back to the first view. */
  activeView?: string
  /** Uncontrolled starting view. Default: the first view. */
  defaultActiveView?: string
  onActiveViewChange?: (key: string) => void
  /** Set false to drop the checkbox column. Default true. */
  selectable?: boolean
  /** Controlled selection, by row id. Omit to let the table keep it. */
  selectedIds?: string[]
  /** Uncontrolled starting selection. */
  defaultSelectedIds?: string[]
  /**
   * Called with the next ids after a toggle, select-all, clear, or when rows
   * disappear (a view change or new data). It never includes a hidden row.
   */
  onSelectionChange?: (ids: string[]) => void
  /** Rendered beside the count. Receives the selected rows, in table order. */
  actions?: (selectedRows: T[]) => React.ReactNode
  /** Starting sort. Click a sortable heading to cycle ascending, descending, off. */
  defaultSort?: SortState
  onSortChange?: (sort: SortState) => void
  /** When the rows were fetched. Shows "As of <time>". Omit to hide the line. */
  asOf?: Date | string | number
  /** How old `asOf` may get before the line warns. Default 60. */
  staleAfterMinutes?: number
  /** Format `asOf` for display. Default: locale date and time. */
  formatAsOf?: (date: Date) => string
  /** Adds a Refresh button to the freshness line. */
  onRefresh?: () => void
  /**
   * The rows could not be loaded. `true` shows a default message; a string is
   * used as the detail line. Replaces the table, and nothing can be selected.
   */
  error?: boolean | string
  /** Adds a "Try again" button to the error state. */
  onRetry?: () => void
  /** Replaces the default message shown when `rows` is empty. */
  emptyState?: React.ReactNode
  /** Replaces the default message shown when the active view keeps no rows. */
  noMatchState?: React.ReactNode
}

// A stock checkbox draws a tick for "indeterminate" too. Hide the tick and draw
// a dash, so "some selected" looks different from "all selected".
const INDETERMINATE =
  "relative data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:[&_svg]:hidden data-[state=indeterminate]:before:absolute data-[state=indeterminate]:before:top-1/2 data-[state=indeterminate]:before:left-1/2 data-[state=indeterminate]:before:h-0.5 data-[state=indeterminate]:before:w-2 data-[state=indeterminate]:before:-translate-x-1/2 data-[state=indeterminate]:before:-translate-y-1/2 data-[state=indeterminate]:before:rounded-full data-[state=indeterminate]:before:bg-current data-[state=indeterminate]:before:content-['']"

// Widens the click target well past the 16px box without changing the layout.
const HIT_AREA = "relative after:absolute after:-inset-3 after:content-['']"

const defaultFormatAsOf = (date: Date) =>
  new Intl.DateTimeFormat(undefined, {
    dateStyle: "medium",
    timeStyle: "short",
  }).format(date)

/**
 * A list of rows with named views, row selection and an action slot that gets
 * the selected rows. See /docs/components/data-table.
 */
function DataTable<T>({
  rows,
  columns,
  getRowId,
  rowLabel,
  caption,
  noun = "row",
  nounPlural,
  views,
  activeView,
  defaultActiveView,
  onActiveViewChange,
  selectable = true,
  selectedIds: selectedIdsProp,
  defaultSelectedIds,
  onSelectionChange,
  actions,
  defaultSort = null,
  onSortChange,
  asOf,
  staleAfterMinutes = 60,
  formatAsOf = defaultFormatAsOf,
  onRefresh,
  error,
  onRetry,
  emptyState,
  noMatchState,
  className,
  ref,
  ...props
}: DataTableProps<T>) {
  const plural = nounPlural ?? `${noun}s`
  const hasError = Boolean(error)

  const [internalView, setInternalView] = React.useState(defaultActiveView)
  const [sort, setSort] = React.useState<SortState>(defaultSort)
  const [internalSelected, setInternalSelected] = React.useState<string[]>(
    defaultSelectedIds ?? []
  )
  // Polite message for changes that are otherwise only visible: a view or sort.
  const [status, setStatus] = React.useState("")
  // Read after mount, so the server and client render the same text first.
  const [now, setNow] = React.useState<number | null>(null)

  const asOfTime = asOf === undefined ? undefined : new Date(asOf).getTime()
  React.useEffect(() => {
    if (asOfTime === undefined) return
    const tick = () => setNow(Date.now())
    const first = setTimeout(tick, 0)
    const timer = setInterval(tick, 60_000)
    return () => {
      clearTimeout(first)
      clearInterval(timer)
    }
  }, [asOfTime])

  // An error leaves nothing to show, so nothing can be selected or acted on.
  const allRows = hasError ? [] : uniqueById(rows, getRowId)
  const view = findView(views, activeView ?? internalView)
  const counts = views ? countViews(allRows, views) : []
  const sortColumn = sort
    ? columns.find((column) => column.key === sort.key && column.sortValue)
    : undefined
  const filtered = filterByView(allRows, view)
  const visibleRows =
    sort && sortColumn?.sortValue
      ? sortRows(filtered, sortColumn.sortValue, sort.direction)
      : filtered
  const visibleIds = visibleRows.map(getRowId)

  // Selection only ever holds rows that are on screen, so an action never
  // reaches a row the person cannot see. Tell the owner when that trims it.
  const selectedIds = selectedIdsProp ?? internalSelected
  const selected = pruneSelection(selectedIds, visibleIds)
  const trimmed = !sameSelection(selected, selectedIds)
  function setSelected(next: string[]) {
    if (selectedIdsProp === undefined) setInternalSelected(next)
    onSelectionChange?.(next)
  }
  // Uncontrolled: drop the hidden rows from our own state while rendering (React's
  // way to adjust state from props), and remember what we trimmed to so the owner
  // can be told once the render is committed.
  const [trimmedTo, setTrimmedTo] = React.useState<string[] | null>(null)
  const notified = React.useRef<string[] | null>(null)
  if (trimmed && selectedIdsProp === undefined) {
    setInternalSelected(selected)
    setTrimmedTo(selected)
  }
  React.useEffect(() => {
    if (selectedIdsProp !== undefined) {
      // Controlled: the owner holds the ids, so ask it to drop the hidden ones.
      if (trimmed) onSelectionChange?.(selected)
    } else if (trimmedTo && trimmedTo !== notified.current) {
      notified.current = trimmedTo
      onSelectionChange?.(trimmedTo)
    }
  })

  const chosenRows = selectedRows(visibleRows, selected, getRowId)
  const chosen = new Set(selected)
  const state = selectionState(selected, visibleIds)
  const rowHeaderKey =
    columns.find((column) => column.rowHeader)?.key ?? columns[0]?.key
  const nounFor = (n: number) => (n === 1 ? noun : plural)

  function chooseView(next: DataTableView<T>) {
    if (activeView === undefined) setInternalView(next.key)
    onActiveViewChange?.(next.key)
    const count = counts.find((item) => item.key === next.key)
    setStatus(
      `${next.label}: showing ${formatShown(count?.count ?? 0, allRows.length, noun, plural)}.`
    )
  }

  function sortBy(column: DataTableColumn<T>) {
    const next = nextSort(sort, column.key)
    setSort(next)
    onSortChange?.(next)
    setStatus(
      next
        ? `Sorted by ${column.header}, ${next.direction === "asc" ? "ascending" : "descending"}.`
        : "Sorting cleared."
    )
  }

  const stale =
    asOfTime !== undefined &&
    now !== null &&
    isStale(asOfTime, now, staleAfterMinutes)
  const asOfDate = asOfTime === undefined ? undefined : new Date(asOfTime)
  const asOfValid = asOfDate !== undefined && !Number.isNaN(asOfDate.getTime())

  const showCount = !hasError && allRows.length > 0
  // Views only mean something when there are rows to filter.
  const showViews = Boolean(views && views.length > 0) && showCount
  const otherView =
    view && views
      ? counts.find((item) => item.key !== view.key && item.count > 0)
      : undefined

  return (
    <div
      ref={ref}
      data-slot="data-table"
      className={cn("space-y-3", className)}
      {...props}
    >
      {(showViews || asOfDate) && (
        <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
          {showViews && views ? (
            <div
              role="group"
              aria-label="Saved views"
              className="flex flex-wrap gap-2"
            >
              {views.map((item) => {
                const count = counts.find((c) => c.key === item.key)
                const active = item.key === view?.key
                return (
                  <Button
                    key={item.key}
                    size="sm"
                    variant={active ? "default" : "outline"}
                    aria-pressed={active}
                    onClick={() => chooseView(item)}
                  >
                    {item.label}
                    <span className="tabular-nums opacity-80">
                      {count?.count ?? 0}
                    </span>
                    <span className="sr-only">of {allRows.length}</span>
                  </Button>
                )
              })}
            </div>
          ) : (
            <span />
          )}
          {asOfDate && (
            <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
              {stale ? (
                <TriangleAlert
                  className="size-4 text-foreground"
                  aria-hidden="true"
                />
              ) : (
                <Clock className="size-4" aria-hidden="true" />
              )}
              <span>
                As of{" "}
                {asOfValid ? (
                  <time
                    dateTime={asOfDate.toISOString()}
                    suppressHydrationWarning
                  >
                    {formatAsOf(asOfDate)}
                  </time>
                ) : (
                  "an unknown time"
                )}
              </span>
              <span
                role="status"
                className={cn(
                  stale ? "font-medium text-foreground" : "sr-only"
                )}
              >
                {stale
                  ? "May be out of date. Refresh before you act on it."
                  : ""}
              </span>
              {onRefresh && (
                <Button variant="ghost" size="sm" onClick={onRefresh}>
                  Refresh
                </Button>
              )}
            </div>
          )}
        </div>
      )}

      {(showCount || actions) && (
        <div className="flex min-h-9 flex-wrap items-center justify-between gap-x-6 gap-y-2">
          <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground tabular-nums">
            {showCount && (
              <p>
                Showing{" "}
                {formatShown(visibleRows.length, allRows.length, noun, plural)}
              </p>
            )}
            {selectable && selected.length > 0 && (
              <>
                <span
                  aria-hidden="true"
                  className="font-medium text-foreground"
                >
                  {selected.length} selected
                </span>
                <Button
                  variant="ghost"
                  size="sm"
                  aria-label="Clear selection"
                  onClick={() => setSelected(clearSelection())}
                >
                  Clear
                </Button>
              </>
            )}
          </div>
          {actions && (
            <div className="flex flex-wrap items-center gap-3">
              {actions(chosenRows)}
            </div>
          )}
        </div>
      )}

      {hasError ? (
        <div
          role="alert"
          className="rounded-md border border-destructive/40 px-6 py-8 text-center"
        >
          <TriangleAlert
            className="mx-auto mb-2 size-5 text-destructive"
            aria-hidden="true"
          />
          <p className="font-medium">Couldn’t load this list</p>
          <p className="mt-1 text-sm text-muted-foreground">
            {typeof error === "string"
              ? error
              : "The data didn’t arrive, so nothing is shown and nothing was changed. Try again, and if it keeps failing, check the source."}
          </p>
          {onRetry && (
            <Button variant="outline" className="mt-4" onClick={onRetry}>
              Try again
            </Button>
          )}
        </div>
      ) : allRows.length === 0 ? (
        <div className="rounded-md border border-dashed px-6 py-8 text-center">
          {emptyState ?? (
            <>
              <p className="font-medium">No {plural} yet</p>
              <p className="mt-1 text-sm text-muted-foreground">
                Nothing has been added, or the source returned no records. If
                you expected some, check the source.
              </p>
            </>
          )}
        </div>
      ) : visibleRows.length === 0 ? (
        <div className="rounded-md border border-dashed px-6 py-8 text-center">
          {noMatchState ?? (
            <>
              <p className="font-medium">
                No {plural} in “{view?.label}”
              </p>
              <p className="mt-1 text-sm text-muted-foreground">
                {allRows.length === 1
                  ? `The only ${noun} is`
                  : `All ${allRows.length} ${plural} are`}{" "}
                in another view.
              </p>
              {otherView && views && (
                <Button
                  variant="outline"
                  className="mt-4"
                  onClick={() => {
                    const target = views.find((v) => v.key === otherView.key)
                    if (target) chooseView(target)
                  }}
                >
                  Show {otherView.label} ({otherView.count})
                </Button>
              )}
            </>
          )}
        </div>
      ) : (
        <Table>
          <TableCaption className="sr-only">{caption}</TableCaption>
          <TableHeader>
            <TableRow className="hover:bg-transparent">
              {selectable && (
                <TableHead className="w-10">
                  <Checkbox
                    aria-label={`Select all ${visibleRows.length} ${nounFor(visibleRows.length)} shown`}
                    checked={
                      state === "all"
                        ? true
                        : state === "some"
                          ? "indeterminate"
                          : false
                    }
                    className={cn(HIT_AREA, INDETERMINATE)}
                    onCheckedChange={() =>
                      setSelected(
                        state === "all"
                          ? deselectAllVisible(selected, visibleIds)
                          : selectAllVisible(selected, visibleIds)
                      )
                    }
                  />
                </TableHead>
              )}
              {columns.map((column) => {
                const sorted =
                  sort?.key === column.key && column.sortValue
                    ? sort.direction
                    : undefined
                const SortIcon =
                  sorted === "asc"
                    ? ArrowUp
                    : sorted === "desc"
                      ? ArrowDown
                      : ChevronsUpDown
                return (
                  <TableHead
                    key={column.key}
                    scope="col"
                    aria-sort={
                      sorted
                        ? sorted === "asc"
                          ? "ascending"
                          : "descending"
                        : undefined
                    }
                    className={cn(
                      column.align === "right" && "text-right",
                      column.className
                    )}
                  >
                    {column.sortValue ? (
                      <Button
                        variant="ghost"
                        size="sm"
                        className="-mx-2 h-8 px-2 font-medium"
                        onClick={() => sortBy(column)}
                      >
                        {column.header}
                        <SortIcon
                          className={cn("size-3.5", !sorted && "opacity-50")}
                          aria-hidden="true"
                        />
                      </Button>
                    ) : (
                      column.header
                    )}
                  </TableHead>
                )
              })}
            </TableRow>
          </TableHeader>
          <TableBody>
            {visibleRows.map((row) => {
              const id = getRowId(row)
              const isSelected = chosen.has(id)
              return (
                <TableRow
                  key={id}
                  data-state={isSelected ? "selected" : undefined}
                >
                  {selectable && (
                    <TableCell className="w-10">
                      <Checkbox
                        aria-label={`Select ${rowLabel(row)}`}
                        checked={isSelected}
                        className={HIT_AREA}
                        onCheckedChange={() =>
                          setSelected(toggleId(selected, id))
                        }
                      />
                    </TableCell>
                  )}
                  {columns.map((column) => {
                    const cellClass = cn(
                      column.align === "right" && "text-right",
                      column.className
                    )
                    return column.key === rowHeaderKey ? (
                      <TableHead
                        key={column.key}
                        scope="row"
                        className={cn("h-auto p-2 font-medium", cellClass)}
                      >
                        {column.cell(row)}
                      </TableHead>
                    ) : (
                      <TableCell key={column.key} className={cellClass}>
                        {column.cell(row)}
                      </TableCell>
                    )
                  })}
                </TableRow>
              )
            })}
          </TableBody>
        </Table>
      )}

      <p role="status" className="sr-only">
        {selectable ? `${selected.length} selected` : ""}
      </p>
      <p role="status" className="sr-only">
        {status}
      </p>
    </div>
  )
}

export { DataTable }
```

**Step 2.** Add the helpers it imports.

```ts title="lib/table-view.ts"
/**
 * Pure helpers for the data-table pattern: named views with counts, a stable
 * sort, and selection that never outlives the rows it points at. No React and
 * no network, so they are safe to unit test and to reuse on a server.
 *
 * Selection is a list of row ids (strings). Every helper returns a new list
 * and never changes the one it is given.
 */

/** A named preset: the rows it keeps are the ones `filter` returns true for. */
export interface TableView<T> {
  /** Unique within the table. */
  key: string
  /** Shown on the view's button, e.g. "Overdue". */
  label: string
  filter: (row: T) => boolean
}

export interface ViewCount {
  key: string
  label: string
  /** Rows this view keeps. */
  count: number
  /** All rows, whatever the view. The same for every view. */
  total: number
}

export type SortDirection = "asc" | "desc"

/** The column being sorted and which way. `null` means source order. */
export type SortState = { key: string; direction: SortDirection } | null

/** What a column hands the sort. Null, undefined, NaN and invalid dates sort last. */
export type SortValue = string | number | boolean | Date | null | undefined

export type SelectionState = "none" | "some" | "all"

/**
 * Drop rows whose id was already seen, keeping the first. Two rows with one id
 * cannot be told apart by a selection, so the table treats the id as the row.
 */
export function uniqueById<T>(rows: readonly T[], getId: (row: T) => string) {
  const seen = new Set<string>()
  const result: T[] = []
  for (const row of rows) {
    const id = getId(row)
    if (seen.has(id)) continue
    seen.add(id)
    result.push(row)
  }
  return result
}

/** The view whose key matches, else the first view, else undefined. */
export function findView<T>(
  views: readonly TableView<T>[] | undefined,
  key: string | undefined
): TableView<T> | undefined {
  if (!views || views.length === 0) return undefined
  return views.find((view) => view.key === key) ?? views[0]
}

/** The rows a view keeps, in their original order. No view keeps every row. */
export function filterByView<T>(
  rows: readonly T[],
  view: TableView<T> | undefined
): T[] {
  return view ? rows.filter(view.filter) : [...rows]
}

/**
 * How many rows each view keeps out of how many in all. Show both, "7 of 18",
 * so nobody reads a filtered list as the whole of it.
 */
export function countViews<T>(
  rows: readonly T[],
  views: readonly TableView<T>[]
): ViewCount[] {
  return views.map((view) => ({
    key: view.key,
    label: view.label,
    count: rows.filter(view.filter).length,
    total: rows.length,
  }))
}

/** "7 of 18 gauges". The noun follows the total, so "1 of 1 gauge". */
export function formatShown(
  count: number,
  total: number,
  noun = "row",
  nounPlural = `${noun}s`
): string {
  return `${count} of ${total} ${total === 1 ? noun : nounPlural}`
}

const collator = new Intl.Collator(undefined, {
  numeric: true,
  sensitivity: "base",
})

function isMissing(value: SortValue): value is null | undefined {
  return (
    value === null ||
    value === undefined ||
    (typeof value === "number" && Number.isNaN(value)) ||
    (value instanceof Date && Number.isNaN(value.getTime()))
  )
}

function compareValues(a: NonNullable<SortValue>, b: NonNullable<SortValue>) {
  if (typeof a === "number" && typeof b === "number") return a - b
  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime()
  if (typeof a === "boolean" && typeof b === "boolean") {
    return Number(a) - Number(b)
  }
  // Mixed or string values: compare as text, "item 2" before "item 10".
  return collator.compare(String(a), String(b))
}

/**
 * A stable sort: rows that tie keep their source order, in either direction.
 * Rows with no value (see `SortValue`) go last whether ascending or
 * descending, so the gaps never hide the rows that do have a value.
 */
export function sortRows<T>(
  rows: readonly T[],
  getValue: (row: T) => SortValue,
  direction: SortDirection = "asc"
): T[] {
  const sign = direction === "asc" ? 1 : -1
  return rows
    .map((row, index) => ({ row, index, value: getValue(row) }))
    .sort((a, b) => {
      const aMissing = isMissing(a.value)
      const bMissing = isMissing(b.value)
      if (aMissing || bMissing) {
        if (aMissing && bMissing) return a.index - b.index
        return aMissing ? 1 : -1
      }
      const order = compareValues(
        a.value as NonNullable<SortValue>,
        b.value as NonNullable<SortValue>
      )
      return order === 0 ? a.index - b.index : order * sign
    })
    .map((entry) => entry.row)
}

/** The next sort after a click on a column header: ascending, descending, off. */
export function nextSort(current: SortState, key: string): SortState {
  if (!current || current.key !== key) return { key, direction: "asc" }
  return current.direction === "asc" ? { key, direction: "desc" } : null
}

/** A selection with duplicates removed, first occurrence kept. */
function dedupe(ids: readonly string[]) {
  return [...new Set(ids)]
}

/** Add the id if it is not selected, remove it if it is. */
export function toggleId(selected: readonly string[], id: string): string[] {
  const ids = dedupe(selected)
  return ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]
}

/**
 * Select every row that is on screen, keeping what was already selected.
 * "Visible" means the rows left after the active view, not a page of them.
 */
export function selectAllVisible(
  selected: readonly string[],
  visibleIds: readonly string[]
): string[] {
  return dedupe([...selected, ...visibleIds])
}

/** Deselect every visible row. Ids that are not visible stay as they were. */
export function deselectAllVisible(
  selected: readonly string[],
  visibleIds: readonly string[]
): string[] {
  const visible = new Set(visibleIds)
  return dedupe(selected).filter((id) => !visible.has(id))
}

export function clearSelection(): string[] {
  return []
}

/**
 * Keep only ids that still exist. Call it when the view or the data changes so
 * a bulk action never reaches a row the person can no longer see.
 */
export function pruneSelection(
  selected: readonly string[],
  availableIds: readonly string[]
): string[] {
  const available = new Set(availableIds)
  return dedupe(selected).filter((id) => available.has(id))
}

/** Whether none, some or every visible row is selected. No rows counts as none. */
export function selectionState(
  selected: readonly string[],
  visibleIds: readonly string[]
): SelectionState {
  if (visibleIds.length === 0) return "none"
  const chosen = new Set(selected)
  const count = new Set(visibleIds).size
  const hits = [...new Set(visibleIds)].filter((id) => chosen.has(id)).length
  if (hits === 0) return "none"
  return hits === count ? "all" : "some"
}

/** The same ids, whatever the order or repeats. */
export function sameSelection(
  a: readonly string[],
  b: readonly string[]
): boolean {
  const setA = new Set(a)
  const setB = new Set(b)
  return setA.size === setB.size && [...setA].every((id) => setB.has(id))
}

/** The selected rows, in row order, one per id. */
export function selectedRows<T>(
  rows: readonly T[],
  selected: readonly string[],
  getId: (row: T) => string
): T[] {
  const chosen = new Set(selected)
  return uniqueById(rows, getId).filter((row) => chosen.has(getId(row)))
}

/**
 * Whether the data is older than `maxAgeMinutes` at `now` (milliseconds). A
 * time that cannot be read counts as stale, because nobody can vouch for it.
 */
export function isStale(
  asOf: Date | string | number,
  now: number,
  maxAgeMinutes: number
): boolean {
  const time = new Date(asOf).getTime()
  if (Number.isNaN(time)) return true
  return now - time > maxAgeMinutes * 60_000
}
```

**Step 3.** Add the shadcn `button`, `checkbox` and `table`, and install `lucide-react`.
Update the import paths to match your project setup.

## Usage

```tsx
import { ConfirmSend } from "@/components/ui/confirm-send"
import {
  DataTable,
  type DataTableColumn,
  type DataTableView,
} from "@/components/ui/data-table"
```

```tsx
const columns: DataTableColumn<Gauge>[] = [
  {
    key: "gauge",
    header: "Gauge",
    rowHeader: true,
    sortValue: (g) => g.id,
    cell: (g) => g.id,
  },
  {
    key: "owner",
    header: "Owner",
    sortValue: (g) => g.owner,
    cell: (g) => g.owner,
  },
  {
    key: "due",
    header: "Due date",
    sortValue: (g) => g.due,
    cell: (g) => formatDate(g.due),
  },
  { key: "status", header: "Status", cell: (g) => <StatusCell gauge={g} /> },
]

const views: DataTableView<Gauge>[] = [
  { key: "all", label: "All", filter: () => true },
  { key: "overdue", label: "Overdue", filter: (g) => g.status === "overdue" },
]
```

```tsx
<DataTable
  caption="Gauges and when each is next due for calibration"
  noun="gauge"
  rows={gauges}
  columns={columns}
  views={views}
  getRowId={(g) => g.id}
  rowLabel={(g) => `Gauge ${g.id}`}
  selectedIds={selectedIds}
  onSelectionChange={setSelectedIds}
  asOf={fetchedAt}
  onRefresh={refetch}
  error={loadFailed}
  onRetry={refetch}
  actions={(selected) => (
    <ConfirmSend
      count={selected.length}
      noun="gauge owner"
      label="Email a reminder"
      emptyReason="Select at least one gauge first"
      onSend={() => api.remind(selected.map((g) => g.id))}
    />
  )}
/>
```

The table never fetches and never saves. You pass the rows, and `actions` hands back the selected ones. Selection can be controlled with `selectedIds` and `onSelectionChange`, or left out so the table keeps it.

> crisp-ui is UI only. Who may see these rows, and who may act on them, is the
> job of your backend: check it on the server, not by hiding a button. Fetching,
> saving and sending belong to you as well. Nothing here makes a tool compliant
> with any regulation.

Requires Tailwind 3.4 or newer (the parts use `size-*`).

## The pattern

1. **Show the denominator.** "Showing 7 of 18 gauges", always. Each view button carries its own count, so nobody reads a filtered list as the whole of it.
2. **Views, not a filter form.** Named presets such as All, Overdue and Due in 30 days answer the usual question in one click. One is active, and the active one is filled and pressed, not just a different colour.
3. **Select what you can see.** The header checkbox selects every row in the active view, with a dash when only some are selected. Switching view or loading new data drops any selected row that is no longer on screen, so an action never reaches a row the person cannot see.
4. **Say how many are selected, out loud.** "3 selected" is announced politely to screen readers, and shown with a Clear button.
5. **The action receives the rows.** `actions` is called with the selected rows in table order. Put a [`confirm-send`](https://realgood.site/docs/components/confirm-send.md) there so a bulk send still asks "Send to 3 owners?".
6. **Say how old it is.** "As of 1 Oct 2026, 09:30", and when it is older than `staleAfterMinutes` an icon and a sentence: "May be out of date. Refresh before you act on it." Block your action while stale if the list drives a send.
7. **Say why it is empty.** Three different states, each with its own wording: nothing at all, nothing in this view (with a button to another view), and a failed load (with Try again).

## Notes

- **States.** An empty `rows` shows "No gauges yet". A view that keeps nothing shows "No gauges in “Overdue”" and offers the first other view that has rows. `error` replaces the table with an alert, and nothing can be selected while it is showing. If a refresh fails and you would rather keep the old rows on screen, do not pass `error`: pass the old `asOf` and let the stale line warn. Override the first two with `emptyState` and `noMatchState`.
- **Row ids.** Selection is kept by `getRowId`, so it must be stable and unique. If two rows share an id, only the first is shown.
- **Sorting.** A column with `sortValue` gets a heading button: ascending, then descending, then back to the source order. Rows with no value (null, undefined, NaN, an invalid date) always sort last, and rows that tie keep their source order. The sorted column has `aria-sort`, and each change is announced, for example "Sorted by Due date, ascending."
- **Accessibility.** It renders a real `table` with a hidden `caption`, `th scope="col"` headings and a `th scope="row"` on the first column (or the one marked `rowHeader`). Every row checkbox is named "Select `rowLabel`", the header checkbox "Select all 7 gauges shown". Focus rings come from the shadcn `button` and `checkbox`.
- **Status is never colour alone.** `cell` is yours to render. Pair any colour with text or an icon, as the demo does ("Overdue by 3 days" with a warning icon).
- **Controlled and uncontrolled.** `selectedIds` and `activeView` are controlled when you pass them, and `defaultSelectedIds` and `defaultActiveView` set a starting value when you do not. `onSelectionChange` also fires when rows disappear and the selection is trimmed.
- **Freshness.** The stale check runs after the page loads and again every minute, so the server and browser render the same text first. An unreadable `asOf` counts as stale.
- **Size.** Every row in `rows` is rendered and counted on each render, which suits lists up to a few hundred rows.

## What it does not do

- **No pagination.** Narrow with views, or page the data yourself and pass in the page.
- **No column resizing or reordering.**
- **No data fetching or saving.** Fetch in your own code and pass `rows`. It does not poll, and it does not write anything.
- **No access control.** It shows the rows you give it.
- **No search box or filter form.** Add a view for the case you need.
- **No loading skeleton.** Render your own while you fetch.

## Props

### DataTable

Also accepts the props of a `div` (except `children`), including `className` and `ref`. They are applied to the root element. The prop types are exported as `DataTableProps<T>`.

| Prop                          | Type                               | Description                                                                           |
| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------- |
| `rows`                        | `T[]`                              | Every row, before any view is applied. Required.                                      |
| `columns`                     | `DataTableColumn<T>[]`             | One entry per column. Required.                                                       |
| `getRowId`                    | `(row: T) => string`               | A stable, unique id for a row. Required.                                              |
| `rowLabel`                    | `(row: T) => string`               | What a person calls the row, for example "Gauge G-104". Names its checkbox. Required. |
| `caption`                     | `string`                           | Names the table for screen readers. Not shown. Required.                              |
| `noun` / `nounPlural`         | `string`                           | One row and its plural. Default "row" and "rows".                                     |
| `views`                       | `DataTableView<T>[]`               | Named presets. Each button shows its live count.                                      |
| `activeView`                  | `string`                           | Controlled: the key of the active view. An unknown key falls back to the first view.  |
| `defaultActiveView`           | `string`                           | Uncontrolled starting view. Default: the first.                                       |
| `onActiveViewChange`          | `(key: string) => void`            | Called when a view button is pressed.                                                 |
| `selectable`                  | `boolean`                          | Set false to drop the checkbox column. Default `true`.                                |
| `selectedIds`                 | `string[]`                         | Controlled selection, by row id.                                                      |
| `defaultSelectedIds`          | `string[]`                         | Uncontrolled starting selection.                                                      |
| `onSelectionChange`           | `(ids: string[]) => void`          | Called with the next ids. Never includes a row that is hidden or gone.                |
| `actions`                     | `(selectedRows: T[]) => ReactNode` | Rendered beside the count. Receives the selected rows, in table order.                |
| `defaultSort`                 | `SortState`                        | Starting sort: `{ key, direction }` or `null`. Default `null`.                        |
| `onSortChange`                | `(sort: SortState) => void`        | Called when a heading is pressed.                                                     |
| `asOf`                        | `Date \| string \| number`         | When the rows were fetched. Shows "As of …". Omit to hide the line.                   |
| `staleAfterMinutes`           | `number`                           | How old `asOf` may get before the line warns. Default 60.                             |
| `formatAsOf`                  | `(date: Date) => string`           | Format `asOf` for display. Default: locale date and time.                             |
| `onRefresh`                   | `() => void`                       | Adds a Refresh button to the freshness line.                                          |
| `error`                       | `boolean \| string`                | The load failed. `true` shows a default message, a string is used as the detail line. |
| `onRetry`                     | `() => void`                       | Adds a "Try again" button to the error state.                                         |
| `emptyState` / `noMatchState` | `ReactNode`                        | Replace the default message for no rows, and for a view that keeps none.              |

### DataTableColumn

| Field       | Type                    | Description                                                                         |
| ----------- | ----------------------- | ----------------------------------------------------------------------------------- |
| `key`       | `string`                | React key and the key `defaultSort` uses. Unique.                                   |
| `header`    | `string`                | Column heading. Named in the "Sorted by …" announcement.                            |
| `cell`      | `(row: T) => ReactNode` | What the cell shows.                                                                |
| `sortValue` | `(row: T) => SortValue` | Makes the column sortable. A string, number, boolean or Date. Empty values go last. |
| `align`     | `"left" \| "right"`     | Default "left". Use "right" for numbers.                                            |
| `rowHeader` | `boolean`               | Renders this column as the row header. Default: the first column.                   |
| `className` | `string`                | Merged onto the heading and every cell.                                             |

### DataTableView

An alias for `TableView<T>` from `table-view`.

| Field    | Type                  | Description                               |
| -------- | --------------------- | ----------------------------------------- |
| `key`    | `string`              | Unique within the table.                  |
| `label`  | `string`              | Shown on the view's button.               |
| `filter` | `(row: T) => boolean` | Return true to keep the row in this view. |

## Pure helpers

`table-view` has no React and no network, so it is safe on a server and easy to unit test. The table is built from it, and you can use it on its own.

| Function                                                                  | What it does                                                     |
| ------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `countViews(rows, views)`                                                 | `{ key, label, count, total }` for each view: the "7 of 18".     |
| `filterByView(rows, view)`                                                | The rows a view keeps, in source order.                          |
| `formatShown(count, total, noun, nounPlural)`                             | "7 of 18 gauges". The noun follows the total.                    |
| `sortRows(rows, getValue, direction)`                                     | A stable sort. Empty values last in both directions.             |
| `nextSort(current, key)`                                                  | The next sort after a heading click: ascending, descending, off. |
| `toggleId` / `selectAllVisible` / `deselectAllVisible` / `clearSelection` | Selection changes. Each returns a new list.                      |
| `pruneSelection(selected, availableIds)`                                  | Keeps only ids that still exist.                                 |
| `selectionState(selected, visibleIds)`                                    | `"none"`, `"some"` or `"all"`.                                   |
| `selectedRows(rows, selected, getId)`                                     | The selected rows, in row order, one per id.                     |
| `uniqueById(rows, getId)`                                                 | Drops repeated ids, keeping the first.                           |
| `isStale(asOf, now, maxAgeMinutes)`                                       | Whether the data is older than the limit.                        |

## Agent prompt

Paste this into a coding agent in a project that already has shadcn set up. Replace the bracketed parts.

```text
Goal: show my [rows, e.g. gauges due for calibration] as a table with saved views ([e.g. All, Overdue, Due in 30 days]), let me select rows, and [act on the selected rows, e.g. email their owners].

Install:
npx shadcn@latest add https://realgood.site/r/data-table.json
npx shadcn@latest add https://realgood.site/r/confirm-send.json
Read the installed files (components/ui/data-table.tsx, lib/table-view.ts) in full before writing any code. Do not guess props.

Props contract (DataTable<Row>):
- rows: every row, unfiltered. getRowId: stable unique string. rowLabel: row => "Gauge G-104" (names its checkbox). caption: names the table.
- columns: { key, header, cell, sortValue? (makes it sortable), rowHeader? }. Status cells use an icon plus words, never colour alone.
- views: { key, label, filter }[]. The table shows each view's count and "7 of 18".
- selectedIds + onSelectionChange(ids), or omit both. actions={(selectedRows) => <ConfirmSend .../>}.
- asOf (fetch time), onRefresh, error (+ onRetry), emptyState, noMatchState.

Wiring: fetch the rows in my own code and pass them in; the table never fetches. Reset ConfirmSend's sentCount when the selection changes. Do not send while the data is stale or failed to load.

Cover these states: loaded, stale, empty, no match for the active view, error with retry, some selected, none selected (action disabled with a reason).

Acceptance checks (run them and show the output):
1. Typecheck and lint pass, and so do any tests for logic I add.
2. Keyboard only: Tab to a row checkbox, Space selects it, "N selected" appears.
3. Every checkbox has a name that includes its row's label.
4. Changing the view never leaves a hidden row selected.

Out of scope: pagination, column resizing, server-side fetching, access control. Use only what is installed. Add no dependencies and rebuild nothing the files already do. If something is unclear, ask me.
```

## Examples by sector

These are example data and wording only. The component is the same in each; only the rows, views and verbs change. Contacts stay as email addresses.

- **Education.** An absent-today list. Rows are students, views are "Absent today", "Absent 3+ days" and "Unexcused", and the action emails the families of the selected students.
- **Manufacturing.** Overdue calibrations. Rows are gauges, views are "Overdue" and "Due in 30 days", and the action emails each owner.
- **Engineering.** ECOs awaiting review. Rows are engineering change orders, views are "Awaiting me", "Awaiting anyone" and "Past due date", and the action emails the reviewers.
- **Health.** Licences expiring in 60 days. Rows are staff licences, views are "Expiring in 60 days" and "Expired", and the action emails each person a reminder.

## Next

Comes after: [`status-strip`](https://realgood.site/docs/components/status-strip.md). Leads to: [`approval-step`](https://realgood.site/docs/components/approval-step.md), [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
