DataGrid
A table you can work in: sort a column, filter the rows, select some, and page through.
| Last active | |||||
|---|---|---|---|---|---|
| Alex Rivera | Owner | Europe | 214 | Today | |
| Priya Nair | Analyst | India | 388 | Today | |
| Tom Okafor | Analyst | United Kingdom | 96 | Yesterday | |
| Mei Tanaka | Viewer | Japan | 41 | 3 days ago | |
| Sam Whitfield | Admin | North America | 152 | Today |
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.
Install
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| rows / columnsT[] / { key, header, cell?, value?, numeric?, sortable?, filterable? }[] | T[] / { key, header, cell?, value?, numeric?, sortable?, filterable? }[] | Generic over the row. `value` is what sorts and filters; `cell` is what draws. |
| getRowId(row) => string | (row) => string | A stable id, so selection follows the row and not its position. |
| selectableboolean | boolean | A box per row and one for all. Controlled with `selected` / `onSelectedChange`, or not. |
| pageSizenumber | number= 8 | Rows per page; 0 turns paging off. |
| actions(ids) => ReactNode | (ids) => ReactNode | Beside the count while rows are selected. |
| labelstring | string | The 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.
"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>
);
}
"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} />;
}