Skip to content

Data Table

BlockMantine ≥ 9

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.

Terminal window
npx manteen add @house/data-table

Dependencies

  • @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.

data-table.usage.tsx
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.

PropTypeDefaultDescription
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.
loadingbooleanfalseShows skeleton placeholder rows instead of the table body while true.
loadingRowsnumber5Number of skeleton rows rendered while loading.
emptyTitlestring"No results"Title shown by the empty state when there are no rows to display.
emptyDescriptionReactNode"Try adjusting your filters."Description shown by the empty state when there are no rows to display.
onRowClick(row: T) => voidMakes rows clickable, enabling hover highlighting and a pointer cursor, and fires with the clicked row.
getRowKey(row: T, index: number) => string | number(_row, index) => indexDerives 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 };
}