← All components

DataGrid

A table you can work in: sort a column, filter the rows, select some, and page through.

Last active
Alex RiveraOwnerEurope214Today
Priya NairAnalystIndia388Today
Tom OkaforAnalystUnited Kingdom96Yesterday
Mei TanakaViewerJapan413 days ago
Sam WhitfieldAdminNorth America152Today
1–5 of 12

The idea

The Table underneath, plus only what working needs. A header sorts ascending, then descending, then back, and says so twice — aria-sort for a screen reader, and one small arrow that turns over rather than swapping for a second glyph. Selection is kept by row id, so it survives a sort, a filter and a page turn, and a selected row takes a fill as well as a checked box. The empty state names what was searched for, so a filter that went too far does not look like an empty table.

Composes Table and Button.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

DataGrid — usage17 lines
import { DataGrid, Button, type DataGridColumn } from "mihirmodi-ui";

const columns: DataGridColumn<Member>[] = [
  { key: "name", header: "Name" },
  { key: "role", header: "Role" },
  { key: "sessions", header: "Sessions", numeric: true },
];

<DataGrid
  label="Team members"
  rows={members}
  columns={columns}
  getRowId={(m) => m.id}
  selectable
  pageSize={5}
  actions={(ids) => <Button size="sm" onClick={() => exportRows(ids)}>Export</Button>}
/>

Props

PropNotes
rows / columnsT[] / { key, header, cell?, value?, numeric?, sortable?, filterable? }[]Generic over the row. `value` is what sorts and filters; `cell` is what draws.
getRowId(row) => stringA stable id, so selection follows the row and not its position.
selectablebooleanA box per row and one for all. Controlled with `selected` / `onSelectedChange`, or not.
pageSizenumberRows per page; 0 turns paging off.
actions(ids) => ReactNodeBeside the count while rows are selected.
labelstringThe table's accessible name.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/data/DataGrid.tsx321 lines
"use client";

import { useEffect, useId, useMemo, useState, type ReactNode } from "react";
import { Button } from "../actions/Button.js";
import { glyphProps } from "../../lib/glyph.js";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./Table.js";

export type DataGridColumn<T> = {
  key: string;
  header: ReactNode;
  /** Draw the cell. Defaults to the row's value at `key`. */
  cell?: (row: T) => ReactNode;
  /** The value sorted and filtered on. Defaults to the row's value at `key`. */
  value?: (row: T) => string | number | null | undefined;
  /** Click the header to sort by this column. On by default. */
  sortable?: boolean;
  /** Figures: right-aligned in tabular numerals. */
  numeric?: boolean;
  /** Search this column with the filter box. On by default for text. */
  filterable?: boolean;
  /** A fixed width for the column, as CSS. */
  width?: string;
};

export type DataGridSort = { key: string; dir: "asc" | "desc" } | null;

export type DataGridProps<T> = {
  rows: T[];
  columns: DataGridColumn<T>[];
  /** A stable id per row — selection is kept by id, so it survives sorting, filtering and paging. */
  getRowId: (row: T) => string;
  /** What a row's checkbox is called — "Select Ada Byrne". Defaults to the first column's value. */
  rowLabel?: (row: T) => string;
  /** The table's accessible name. */
  label: string;
  /** Show a filter box above the table. */
  filter?: boolean;
  filterPlaceholder?: string;
  /** A box at the start of every row, and one in the header for all of them. */
  selectable?: boolean;
  /** Controlled selection. Leave it out and the grid keeps its own. */
  selected?: string[];
  onSelectedChange?: (ids: string[]) => void;
  /** Rows per page. `0` turns paging off. */
  pageSize?: number;
  defaultSort?: DataGridSort;
  /** Beside the count while rows are selected — "Export", "Archive". */
  actions?: (selected: string[]) => ReactNode;
  /** Shown in place of rows when nothing matches. */
  empty?: ReactNode;
  className?: string;
};

const read = <T,>(row: T, key: string) => (row as Record<string, unknown>)[key];

function Box({ state, label, onToggle, disabled }: { state: boolean | "mixed"; label: string; onToggle: () => void; disabled?: boolean }) {
  const on = state !== false;
  return (
    <button
      type="button"
      role="checkbox"
      aria-checked={state}
      aria-label={label}
      disabled={disabled}
      onClick={onToggle}
      // The hit area is the cell's height; the box drawn inside it is 16px.
      className="group/box -m-1 flex h-6 w-6 disabled:opacity-40 items-center justify-center rounded-[6px] active:scale-[0.94] motion-reduce:active:scale-100"
    >
      <span
        aria-hidden
        className={`flex h-4 w-4 items-center justify-center rounded-[5px] transition-[background-color,box-shadow] duration-150 ease-out ${
          on
            ? "bg-ink text-background-100"
            : "bg-background-100 shadow-[inset_0_0_0_1px_var(--color-gray-alpha-500)] group-hover/box:shadow-[inset_0_0_0_1px_var(--color-gray-alpha-600)]"
        }`}
      >
        <svg {...glyphProps} strokeWidth="2.4" className={`h-3 w-3 transition-opacity duration-150 ${on ? "opacity-100" : "opacity-0"}`}>
          {state === "mixed" ? <path d="M7 12h10" /> : <path d="M5.5 12.5l4 4 9-9" />}
        </svg>
      </span>
    </button>
  );
}

function SortMark({ dir }: { dir: "asc" | "desc" | null }) {
  return (
    <svg
      {...glyphProps}
      strokeWidth="1.8"
      className={`h-3 w-3 shrink-0 transition-[transform,opacity] duration-200 ease-geist motion-reduce:transition-none ${
        dir === "desc" ? "rotate-180" : ""
      } ${dir ? "opacity-100" : "opacity-0 group-hover/sort:opacity-60"}`}
    >
      <path d="M12 18.5V5.5M6.5 11L12 5.5 17.5 11" />
    </svg>
  );
}

/**
 * A table you can work in: sort by a column, filter the rows, select some, and
 * page through them.
 *
 * It is the `Table` underneath, so it reads the same — rules not boxes, figures
 * right-aligned in tabular numerals — and adds only what working needs. A header
 * sorts on a click, ascending, then descending, then back to the order the rows
 * came in, and says so twice: `aria-sort` for a screen reader and one small
 * arrow that turns over rather than swapping for a second glyph, so the change
 * reads as the same column flipping. The arrow is hidden on unsorted columns
 * until you point at the header, so a row of headers is not a row of icons.
 *
 * Selection is kept by row id, not position, so it survives a sort, a filter
 * and a page turn; the header's box selects every row the filter lets through,
 * and goes to a dash when only some are. A selected row takes a fill as well as
 * a checked box, because a state the eye should find in a long table needs more
 * than a mark at one edge. The empty state says what was searched for, so the
 * reader can tell an empty table from a filter that went too far.
 */
export function DataGrid<T>({
  rows,
  columns,
  getRowId,
  rowLabel,
  label,
  filter = true,
  filterPlaceholder = "Filter…",
  selectable = false,
  selected: selectedProp,
  onSelectedChange,
  pageSize = 8,
  defaultSort = null,
  actions,
  empty,
  className = "",
}: DataGridProps<T>) {
  const filterId = useId();
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<DataGridSort>(defaultSort);
  const [page, setPage] = useState(1);
  const [own, setOwn] = useState<string[]>([]);
  const selected = selectedProp ?? own;
  const setSelected = (ids: string[]) => {
    if (selectedProp === undefined) setOwn(ids);
    onSelectedChange?.(ids);
  };

  const valueOf = (row: T, col: DataGridColumn<T>) => (col.value ? col.value(row) : (read(row, col.key) as string | number | null | undefined));

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return rows;
    const cols = columns.filter((c) => c.filterable ?? !c.numeric);
    return rows.filter((r) => cols.some((c) => String(valueOf(r, c) ?? "").toLowerCase().includes(q)));
  }, [rows, columns, query]);

  const sorted = useMemo(() => {
    if (!sort) return filtered;
    const col = columns.find((c) => c.key === sort.key);
    if (!col) return filtered;
    const sign = sort.dir === "asc" ? 1 : -1;
    // Sort a copy with the index as the tiebreak, so equal rows keep their order.
    return filtered
      .map((r, i) => ({ r, i, v: valueOf(r, col) }))
      .sort((a, b) => {
        if (a.v == null || b.v == null) return a.v == null ? (b.v == null ? a.i - b.i : 1) : -1;
        const d = typeof a.v === "number" && typeof b.v === "number" ? a.v - b.v : String(a.v).localeCompare(String(b.v), undefined, { numeric: true });
        return d === 0 ? a.i - b.i : d * sign;
      })
      .map((x) => x.r);
  }, [filtered, sort, columns]);

  const pages = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
  useEffect(() => {
    if (page > pages) setPage(pages);
  }, [page, pages]);
  const at = Math.min(page, pages);
  const shown = pageSize > 0 ? sorted.slice((at - 1) * pageSize, at * pageSize) : sorted;

  const ids = sorted.map(getRowId);
  const picked = ids.filter((i) => selected.includes(i)).length;
  const all: boolean | "mixed" = picked === 0 ? false : picked === ids.length ? true : "mixed";

  const toggleRow = (id: string) => setSelected(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
  const toggleAll = () => setSelected(all === true ? selected.filter((x) => !ids.includes(x)) : Array.from(new Set([...selected, ...ids])));

  const cycle = (key: string) => {
    setSort((s) => (!s || s.key !== key ? { key, dir: "asc" } : s.dir === "asc" ? { key, dir: "desc" } : null));
    setPage(1);
  };

  const span = columns.length + (selectable ? 1 : 0);
  const from = sorted.length ? (at - 1) * (pageSize || sorted.length) + 1 : 0;
  const to = pageSize > 0 ? Math.min(at * pageSize, sorted.length) : sorted.length;

  return (
    <div className={className}>
      {(filter || (selectable && selected.length > 0)) && (
        <div className="mb-3 flex min-h-8 flex-wrap items-center justify-between gap-3">
          {filter ? (
            <div className="relative w-full max-w-[240px]">
              <label htmlFor={filterId} className="sr-only">
                Filter {label}
              </label>
              <svg {...glyphProps} className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-faint">
                <circle cx="11" cy="11" r="6.5" />
                <path d="M20 20l-4.2-4.2" />
              </svg>
              <input
                id={filterId}
                type="search"
                value={query}
                onChange={(e) => {
                  setQuery(e.target.value);
                  setPage(1);
                }}
                placeholder={filterPlaceholder}
                className="h-8 w-full rounded-[8px] border-[0.5px] border-gray-alpha-400 bg-background-100 pl-8 pr-2.5 text-[13px] text-gray-1000 transition-[border-color,box-shadow] duration-150 ease-out placeholder:text-faint hover:border-gray-alpha-600 focus:border-gray-alpha-600 [&::-webkit-search-cancel-button]:hidden"
              />
            </div>
          ) : (
            <span />
          )}
          {selectable && selected.length > 0 && (
            <div className="flex items-center gap-2">
              <span className="text-[12px] tabular-nums text-muted" aria-live="polite">
                {selected.length} selected
              </span>
              {actions?.(selected)}
            </div>
          )}
        </div>
      )}

      <Table aria-label={label}>
        <TableHeader>
          <TableRow>
            {selectable && (
              <TableHead className="w-10 pr-0">
                <Box state={all} label="Select all rows" onToggle={toggleAll} disabled={ids.length === 0} />
              </TableHead>
            )}
            {columns.map((c) => {
              const canSort = c.sortable ?? true;
              const dir = sort?.key === c.key ? sort.dir : null;
              return (
                <TableHead
                  key={c.key}
                  numeric={c.numeric}
                  style={c.width ? { width: c.width } : undefined}
                  aria-sort={canSort ? (dir === "asc" ? "ascending" : dir === "desc" ? "descending" : "none") : undefined}
                >
                  {canSort ? (
                    <button
                      type="button"
                      onClick={() => cycle(c.key)}
                      className={`group/sort -mx-1 inline-flex items-center gap-1 rounded-[6px] px-1 py-0.5 transition-colors duration-150 ease-out hover:text-gray-1000 ${
                        c.numeric ? "flex-row-reverse" : ""
                      } ${dir ? "text-gray-1000" : ""}`}
                    >
                      {c.header}
                      <SortMark dir={dir} />
                    </button>
                  ) : (
                    c.header
                  )}
                </TableHead>
              );
            })}
          </TableRow>
        </TableHeader>
        <TableBody>
          {shown.length === 0 ? (
            <tr>
              <TableCell colSpan={span} align="center" className="h-24 text-faint">
                {empty ?? (query.trim() ? `Nothing matches “${query.trim()}”.` : "Nothing here yet.")}
              </TableCell>
            </tr>
          ) : (
            shown.map((r) => {
              const id = getRowId(r);
              const on = selected.includes(id);
              return (
                <TableRow key={id} selected={selectable && on}>
                  {selectable && (
                    <TableCell className="w-10 pr-0">
                      <Box state={on} label={`Select ${rowLabel ? rowLabel(r) : String(valueOf(r, columns[0]) ?? id)}`} onToggle={() => toggleRow(id)} />
                    </TableCell>
                  )}
                  {columns.map((c) => (
                    <TableCell key={c.key} numeric={c.numeric}>
                      {c.cell ? c.cell(r) : String(read(r, c.key) ?? "")}
                    </TableCell>
                  ))}
                </TableRow>
              );
            })
          )}
        </TableBody>
      </Table>

      {pageSize > 0 && sorted.length > pageSize && (
        <div className="mt-3 flex items-center justify-between gap-3">
          <span className="text-[12px] tabular-nums text-muted">
            {from}–{to} of {sorted.length}
          </span>
          <div className="flex items-center gap-1">
            <Button variant="ghost" size="icon-sm" aria-label="Previous page" disabled={at <= 1} onClick={() => setPage(at - 1)}>
              <svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
                <path d="M14.5 6l-6 6 6 6" />
              </svg>
            </Button>
            <Button variant="ghost" size="icon-sm" aria-label="Next page" disabled={at >= pages} onClick={() => setPage(at + 1)}>
              <svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
                <path d="M9.5 6l6 6-6 6" />
              </svg>
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}
packages/ui/src/components/data/Table.tsx130 lines
"use client";

import { createContext, useContext, type HTMLAttributes, type ReactNode, type TdHTMLAttributes, type ThHTMLAttributes } from "react";

type Align = "left" | "right" | "center";

const TableContext = createContext<{ sticky: boolean; dense: boolean }>({ sticky: false, dense: false });

export type TableProps = HTMLAttributes<HTMLTableElement> & {
  /**
   * Keep the header row in view while the body scrolls under it. Give the table
   * a `maxHeight` too, or it is the page that scrolls and the header sticks to
   * the top of the window.
   */
  sticky?: boolean;
  /** Scroll the body inside this height, rather than the page. */
  maxHeight?: number | string;
  /** Rows 32px tall instead of 40. */
  dense?: boolean;
  /** On the scrolling box around the table. */
  wrapperClassName?: string;
  children: ReactNode;
};

/**
 * A table drawn with rules, not boxes.
 *
 * One hairline under each row and nothing else — no zebra, no vertical lines,
 * no frame. Stripes and grid lines are what a table needs when its columns are
 * badly aligned; aligned columns carry the eye across a row by themselves, and
 * every extra line is one more thing to read past. So the alignment is done
 * properly instead: figures sit right, in tabular numerals, so the units line
 * up down a column and a longer number is visibly a bigger one. A row under the
 * pointer takes the faintest grey, enough to keep your place across a wide row.
 *
 * The parts are the native elements with the house look on them, so anything
 * you know about tables — `colSpan`, `scope`, a `<caption>` — still works. With
 * `sticky` the header holds while the rows scroll beneath it, on the frosted
 * menu glass, since it is now something floating over content.
 */
export function Table({ sticky = false, maxHeight, dense = false, wrapperClassName = "", className = "", children, ...rest }: TableProps) {
  return (
    <TableContext.Provider value={{ sticky, dense }}>
      <div className={`relative w-full overflow-auto ${wrapperClassName}`} style={maxHeight !== undefined ? { maxHeight } : undefined}>
        {/* Separate borders, so a sticky header cell carries its own rule with it. */}
        <table className={`w-full border-separate border-spacing-0 text-[13px] ${className}`} {...rest}>
          {children}
        </table>
      </div>
    </TableContext.Provider>
  );
}

export type TableSectionProps = HTMLAttributes<HTMLTableSectionElement>;

export function TableHeader({ className = "", ...rest }: TableSectionProps) {
  return <thead className={className} {...rest} />;
}

export function TableBody({ className = "", ...rest }: TableSectionProps) {
  return <tbody className={className} {...rest} />;
}

/** Totals. A rule above, and the figures in medium weight — the one row that is a sum of the others. */
export function TableFooter({ className = "", ...rest }: TableSectionProps) {
  return <tfoot className={`font-medium [&_td]:border-b-0 [&_td]:border-t-[0.5px] [&_td]:border-gray-alpha-500 ${className}`} {...rest} />;
}

export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
  /** A held state — a selected row. Drawn as a fill, never by the text colour alone. */
  selected?: boolean;
};

export function TableRow({ selected, className = "", ...rest }: TableRowProps) {
  return (
    <tr
      data-selected={selected || undefined}
      className={`transition-colors duration-150 ease-out ${
        selected ? "bg-gray-alpha-100 [tbody_&]:hover:bg-gray-alpha-200" : "[tbody_&]:hover:bg-gray-alpha-100"
      } ${className}`}
      {...rest}
    />
  );
}

const ALIGN: Record<Align, string> = { left: "text-left", right: "text-right", center: "text-center" };

export type TableHeadProps = ThHTMLAttributes<HTMLTableCellElement> & {
  align?: Align;
  /** A column of figures: right-aligned, so it lines up with its cells. */
  numeric?: boolean;
};

export function TableHead({ align, numeric, className = "", scope = "col", ...rest }: TableHeadProps) {
  const { sticky } = useContext(TableContext);
  const a = align ?? (numeric ? "right" : "left");
  return (
    <th
      scope={scope}
      className={`h-9 whitespace-nowrap border-b-[0.5px] border-gray-alpha-400 px-3 align-middle text-[12px] font-medium text-muted ${ALIGN[a]} ${
        sticky ? "glass-menu sticky top-0 z-10" : ""
      } ${className}`}
      {...rest}
    />
  );
}

export type TableCellProps = TdHTMLAttributes<HTMLTableCellElement> & {
  align?: Align;
  /** Figures: right-aligned in tabular numerals, so the units line up down the column. */
  numeric?: boolean;
};

export function TableCell({ align, numeric, className = "", ...rest }: TableCellProps) {
  const { dense } = useContext(TableContext);
  const a = align ?? (numeric ? "right" : "left");
  return (
    <td
      className={`border-b-[0.5px] border-gray-alpha-400 px-3 align-middle text-gray-1000 ${dense ? "h-8 py-1" : "h-10 py-2"} ${
        numeric ? "tabular-nums" : ""
      } ${ALIGN[a]} ${className}`}
      {...rest}
    />
  );
}

/** What the table is, under it, in the meta size. Read first by a screen reader, whatever its place on screen. */
export function TableCaption({ className = "", ...rest }: HTMLAttributes<HTMLTableCaptionElement>) {
  return <caption className={`mt-3 caption-bottom text-left text-[12px] leading-[1.5] text-faint ${className}`} {...rest} />;
}