Data Table
Sortable table with loading skeletons, click-through rows and an empty state.
Install
One command copies the item's source into your project, where you own and edit it.
npx manteen add @house/data-tableDependencies
@mantine/core@^9npm@tabler/icons-react@^3npm
Installing this item also installs @house/empty-state from the registry.
Preview
Usage
A copy-ready example published by the registry author. Imports use the aliases Manteen configures at init.
import { Badge } from "@mantine/core";import { DataTable } from "@/components/ui/data-table";
interface Invoice extends Record<string, unknown> { id: string; client: string; amount: number; status: "paid" | "overdue" | "pending";}
const invoices: Invoice[] = [ { id: "INV-1042", client: "Bluefin Studio", amount: 1280, status: "paid" }, { id: "INV-1043", client: "Northwind Labs", amount: 640, status: "pending" }, { id: "INV-1044", client: "Harbor & Co.", amount: 2150, status: "overdue" },];
const statusColor: Record<Invoice["status"], string> = { paid: "teal", pending: "yellow", overdue: "red",};
export function InvoicesTable() { return ( <DataTable<Invoice> data={invoices} columns={[ { key: "id", header: "Invoice", sortable: true }, { key: "client", header: "Client", sortable: true }, { key: "amount", header: "Amount", sortable: true, render: (row) => `$${row.amount.toFixed(2)}`, }, { key: "status", header: "Status", render: (row) => <Badge color={statusColor[row.status]}>{row.status}</Badge>, }, ]} onRowClick={(row) => console.log("open invoice", row.id)} emptyTitle="No invoices yet" /> );}Props
Author-documented props, carried verbatim from the registry item.
| Prop | Type | Default | Description |
|---|---|---|---|
data* | T[] | — | Rows to render. Sorting a column header does not mutate this array — internally the table sorts a copy of it and re-renders with the copy. |
columns* | DataTableColumn<T>[] | — | Column definitions controlling header text, width, sortability and per-cell rendering. |
loading | boolean | false | Shows skeleton placeholder rows instead of the table body while true. |
loadingRows | number | 5 | Number of skeleton rows rendered while loading. |
emptyTitle | string | "No results" | Title shown by the empty state when there are no rows to display. |
emptyDescription | ReactNode | "Try adjusting your filters." | Description shown by the empty state when there are no rows to display. |
onRowClick | (row: T) => void | — | Makes rows clickable, enabling hover highlighting and a pointer cursor, and fires with the clicked row. |
getRowKey | (row: T, index: number) => string | number | (_row, index) => index | Derives the React key for each row; defaults to the row's index. |
* required
Styling
No public Styles API is declared. Installed CSS remains editable because your project owns the source; internal class names are implementation details, not a customization contract.
registry/lib/data-table.theme.tstheme fragment · 629 B
Folded into the configured project theme; not written at this source path.
import { createTheme, Skeleton, Table } from "@mantine/core";
/** * Theme fragment for the `data-table` item. * * Declared as `themeFragment` in manteen.registry.json. The Mantine client * merges it into the project's existing theme via tools/merge-theme, so two * items can both contribute `theme.components` entries without clobbering. */export const theme = createTheme({ components: { Table: Table.extend({ defaultProps: { verticalSpacing: "sm", highlightOnHover: true, }, }), Skeleton: Skeleton.extend({ defaultProps: { radius: "sm", }, }), },});Source
2 installable files ship with this item.
registry/blocks/data-table/data-table.tsxcomponent · 4.0 kB · tsx
import { Group, Skeleton, Stack, Table, Text, UnstyledButton } from "@mantine/core";import { IconChevronDown, IconChevronUp, IconSelector } from "@tabler/icons-react";import type { ReactNode } from "react";
import { EmptyState } from "@/components/ui/empty-state";import { useDataTable } from "@/hooks/use-data-table";
export interface DataTableColumn<T> { key: keyof T; header: ReactNode; sortable?: boolean; width?: number | string; render?: (row: T) => ReactNode;}
export interface DataTableProps<T extends Record<string, unknown>> { data: T[]; columns: DataTableColumn<T>[]; loading?: boolean; /** Number of skeleton rows to show while `loading`. */ loadingRows?: number; emptyTitle?: string; emptyDescription?: ReactNode; onRowClick?: (row: T) => void; getRowKey?: (row: T, index: number) => string | number;}
export function DataTable<T extends Record<string, unknown>>({ data, columns, loading = false, loadingRows = 5, emptyTitle = "No results", emptyDescription = "Try adjusting your filters.", onRowClick, getRowKey = (_row, index) => index,}: DataTableProps<T>) { const { rows, sort, toggleSort } = useDataTable(data);
if (loading) { return ( <Stack gap="xs"> {Array.from({ length: loadingRows }, (_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length loading placeholders with no identity and no reordering, so the index IS the stable key. <Skeleton key={i} height={40} radius="sm" /> ))} </Stack> ); }
if (!rows.length) { return <EmptyState title={emptyTitle} description={emptyDescription} />; }
return ( // minWidth is the point below which the table scrolls horizontally instead of squeezing // columns into wrapped, multi-line text. 420 was measured against this demo's four columns // (id, name, currency + sort icon, status badge) with a small safety margin; it does not // eliminate horizontal scroll on common phones (390-412px) but is 60px closer than the // previous 480. Consumers adding wider or more columns should raise it — a lower floor // forces wrapping sooner as column count/content grows. <Table.ScrollContainer minWidth={420}> <Table highlightOnHover={Boolean(onRowClick)} verticalSpacing="sm"> <Table.Thead> <Table.Tr> {columns.map((column) => ( <Table.Th key={String(column.key)} w={column.width}> {column.sortable ? ( <UnstyledButton onClick={() => toggleSort(column.key)}> <Group gap={4} wrap="nowrap"> <Text size="sm" fw={600}> {column.header} </Text> <SortIcon active={sort.key === column.key} direction={sort.direction} /> </Group> </UnstyledButton> ) : ( <Text size="sm" fw={600}> {column.header} </Text> )} </Table.Th> ))} </Table.Tr> </Table.Thead>
<Table.Tbody> {rows.map((row, index) => ( <Table.Tr key={getRowKey(row, index)} onClick={onRowClick ? () => onRowClick(row) : undefined} style={onRowClick ? { cursor: "pointer" } : undefined} > {columns.map((column) => ( <Table.Td key={String(column.key)}> {column.render ? column.render(row) : String(row[column.key] ?? "—")} </Table.Td> ))} </Table.Tr> ))} </Table.Tbody> </Table> </Table.ScrollContainer> );}
function SortIcon({ active, direction }: { active: boolean; direction: "asc" | "desc" }) { if (!active) return <IconSelector size={14} opacity={0.5} />; return direction === "asc" ? <IconChevronUp size={14} /> : <IconChevronDown size={14} />;}registry/blocks/data-table/use-data-table.tshook · 1.3 kB · ts
import { useMemo, useState } from "react";
export type SortDirection = "asc" | "desc";
export interface SortState<T> { key: keyof T | null; direction: SortDirection;}
/** * Client-side sorting for `<DataTable />`. * * Swap this out for a server-driven implementation by keeping the same return * shape — the table component only cares about `rows`, `sort` and `toggleSort`. */export function useDataTable<T extends Record<string, unknown>>(data: T[]) { const [sort, setSort] = useState<SortState<T>>({ key: null, direction: "asc" });
const rows = useMemo(() => { if (!sort.key) return data;
const key = sort.key; return [...data].sort((a, b) => { const av = a[key]; const bv = b[key];
if (av === bv) return 0; if (av == null) return 1; if (bv == null) return -1;
const result = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return sort.direction === "asc" ? result : -result; }); }, [data, sort]);
function toggleSort(key: keyof T) { setSort((current) => current.key === key ? { key, direction: current.direction === "asc" ? "desc" : "asc" } : { key, direction: "asc" }, ); }
return { rows, sort, toggleSort };}