Data Table
The table from the Notra dashboard, with sorting, selection, pagination and an infinite-scroll variant.
Every table in Notra uses the same frame: a grey shell with a 2px rim on all four sides, the column headers on the shell, and the rows on a lifted white card. DataTable adds sorting, row selection, column resizing, a sticky header with a virtualized body, and a footer pager.
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { DataTable, type TableColumn } from "../components/data-table";
interface Prompt {
id: string;
prompt: string;
engine: string;
status: "mentioned" | "missing";
position: number | null;
}
const ENGINES = ["ChatGPT", "Claude", "Gemini", "Perplexity"] as const;
const TOPICS = [
"best AI meeting notes app for remote teams",
"alternatives to Quillboard for startups",
"privacy-friendly note taker for EU companies",
"tools that summarize Zoom calls",
"how to build a team knowledge base",
"AI notes that integrate with Slack and Linear",
] as const;
const PAGE_SIZE = 10;
const ROW_HEIGHT = 48;
const VISIBLE_ROWS = 10;
/**
* A full page plus the pager and padding. The preview keeps this height on the
* short last page, so the docs frame and the page below it don't resize.
*/
const PREVIEW_MIN_HEIGHT = 628;
const PROMPTS: Prompt[] = Array.from({ length: 36 }, (_, index) => {
const mentioned = index % 3 !== 1;
return {
id: `prompt-${index}`,
prompt: TOPICS[index % TOPICS.length] ?? "",
engine: ENGINES[index % ENGINES.length] ?? "",
status: mentioned ? "mentioned" : "missing",
position: mentioned ? (index % 5) + 1 : null,
};
});
const columns: TableColumn<Prompt>[] = [
{ key: "prompt", header: "Prompt", sortable: true },
{
key: "engine",
header: "Engine",
width: "7.5rem",
sortable: true,
collapsePriority: 1,
},
{
key: "status",
header: "Mention",
width: "9rem",
cell: (row) =>
row.status === "mentioned" ? (
<Badge variant="secondary">Mentioned</Badge>
) : (
<Badge variant="outline">Not mentioned</Badge>
),
},
{
key: "position",
header: "Position",
width: "7rem",
align: "right",
sortable: true,
hint: "Where the brand first shows up in the answer.",
sortValue: (row) => row.position ?? Number.POSITIVE_INFINITY,
cell: (row) => (row.position === null ? "–" : `#${row.position}`),
},
];
export default function DataTableDemo() {
const [page, setPage] = useState(1);
return (
<div
className="w-full max-w-4xl self-start p-6"
style={{ minHeight: PREVIEW_MIN_HEIGHT }}
>
<DataTable
columns={columns}
data={PROMPTS}
getRowId={(row) => row.id}
height={(VISIBLE_ROWS + 1) * ROW_HEIGHT}
pagination={{
page,
pageSize: PAGE_SIZE,
onPageChange: setPage,
itemLabel: "prompts",
}}
rowHeight={ROW_HEIGHT}
selectable
/>
</div>
);
}
Installation
$ bunx --bun shadcn@latest add @notra/data-tableThis installs the table into components/data-table/ and replaces components/ui/table.tsx with the framed Table primitives. It also pulls in Button and Tooltip, plus shadcn’s Checkbox, Select, Context Menu and Skeleton.
The table needs a shadcn project on Base UI (a base-* style) and Tailwind CSS v4. On a Radix style, the Checkbox, Tooltip and Context Menu APIs differ and the table won’t compile.
Usage
Describe the columns once and pass the rows. A column reads row[key] unless it has a cell renderer, and sorts by the same value unless it has sortValue.
import { DataTable, type TableColumn } from "@/components/data-table/components/data-table";
const columns: TableColumn<Prompt>[] = [
{ key: "prompt", header: "Prompt", width: "2fr", sortable: true },
{ key: "engine", header: "Engine", collapsePriority: 1 },
{
key: "position",
header: "Position",
align: "right",
sortable: true,
cell: (row) => (row.position === null ? "–" : `#${row.position}`),
},
];
<DataTable columns={columns} data={prompts} getRowId={(row) => row.id} />;
width takes any CSS length or an fr share. Columns with a collapsePriority drop out on narrow screens instead of scrolling sideways, highest number first. A hint adds an info icon with a tooltip to the header.
Pagination
Pass pagination and the footer shows a page-size select on the left and “Page 2 of 9” with previous and next buttons on the right. Changing the page size jumps back to the first page.
| Mode | When | You pass |
|---|---|---|
client (default) |
Every row is loaded. The table slices them after sorting and keeps the chosen page size itself. | page, pageSize, onPageChange |
server |
data is the current page and the server knows the total. |
plus totalItems and onPageSizeChange |
cursor |
data is the current page and you only know whether more exist. |
plus hasNextPage and onPageSizeChange |
<DataTable
columns={columns}
data={rows}
pagination={{
mode: "server",
page,
pageSize,
totalItems: total,
onPageChange: setPage,
onPageSizeChange: setPageSize,
itemLabel: "prompts",
}}
/>
Server and cursor tables without onPageSizeChange show the range, like “11-20 of 170 prompts”, instead of the select. pageSizeOptions changes the offered sizes (10 to 50 by default), and formatRange rewrites the range text.
Infinite scroll
InfiniteDataTable keeps a fixed height and calls onEndReached when the reader nears the bottom. Clear it while a page loads or when nothing is left, and set loadingMore to show skeleton rows under the list.
"use client";
import { useState } from "react";
import { InfiniteDataTable, type TableColumn } from "../components/data-table";
interface Crawler {
name: string;
/** Provider id on models.dev, e.g. `openai` for models.dev/logos/openai.svg. */
provider: string;
}
interface Visit {
id: string;
bot: Crawler;
path: string;
status: number;
}
const BOTS: readonly Crawler[] = [
{ name: "GPTBot", provider: "openai" },
{ name: "ClaudeBot", provider: "anthropic" },
{ name: "PerplexityBot", provider: "perplexity" },
{ name: "Googlebot", provider: "google" },
];
const PATHS = [
"/",
"/pricing",
"/blog/ai-notes",
"/docs",
"/changelog",
] as const;
const PAGE = 25;
const MAX_ROWS = 200;
const LOAD_DELAY_MS = 600;
const makeVisits = (from: number): Visit[] =>
Array.from({ length: PAGE }, (_, offset) => {
const index = from + offset;
return {
id: `visit-${index}`,
bot: BOTS[index % BOTS.length] ?? { name: "", provider: "" },
path: PATHS[index % PATHS.length] ?? "",
status: index % 11 === 0 ? 404 : 200,
};
});
const columns: TableColumn<Visit>[] = [
{
key: "bot",
header: "Crawler",
width: "11rem",
cell: ({ bot }) => (
<span className="flex items-center gap-2">
<img
alt=""
className="size-4 shrink-0 dark:invert"
height={16}
src={`https://models.dev/logos/${bot.provider}.svg`}
width={16}
/>
<span className="truncate">{bot.name}</span>
</span>
),
},
{ key: "path", header: "Page" },
{ key: "status", header: "Status", width: "6rem", align: "right" },
];
export default function DataTableInfiniteExample() {
const [rows, setRows] = useState(() => makeVisits(0));
const [loading, setLoading] = useState(false);
const hasMore = rows.length < MAX_ROWS;
const loadMore = () => {
setLoading(true);
setTimeout(() => {
setRows((current) => [...current, ...makeVisits(current.length)]);
setLoading(false);
}, LOAD_DELAY_MS);
};
return (
<div className="w-full max-w-4xl p-6">
<InfiniteDataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
height={400}
loading={loading}
loadingMore={loading}
onEndReached={hasMore && !loading ? loadMore : undefined}
/>
</div>
);
}
<InfiniteDataTable
columns={columns}
data={rows}
height={400}
loading={isFetching}
loadingMore={isFetchingNextPage}
onEndReached={hasNextPage && !isFetching ? fetchNextPage : undefined}
/>
Loading and empty states
loading fills an empty table with skeleton rows and dims a table that already has rows. DataTableSkeleton renders the same frame on its own for route-level loading. emptyState takes text or any node, such as an Empty block with an action.
"use client";
import { BotIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { DataTable, type TableColumn } from "../components/data-table";
interface Visit {
id: string;
bot: string;
path: string;
status: number;
}
const SAMPLE_VISITS: Visit[] = [
{ id: "visit-1", bot: "GPTBot", path: "/pricing", status: 200 },
{ id: "visit-2", bot: "ClaudeBot", path: "/docs", status: 200 },
{ id: "visit-3", bot: "PerplexityBot", path: "/blog/ai-notes", status: 200 },
{ id: "visit-4", bot: "Googlebot", path: "/changelog", status: 404 },
];
/** Room for the empty state; a table without rows has no height of its own. */
const EMPTY_HEIGHT = 300;
/**
* The empty table's full height. The preview keeps it once rows arrive and the
* table shrinks to fit them, so the docs frame and the page below don't move.
*/
const PREVIEW_MIN_HEIGHT = 296;
const columns: TableColumn<Visit>[] = [
{ key: "bot", header: "Crawler", width: "11rem" },
{ key: "path", header: "Page" },
{ key: "status", header: "Status", width: "6rem", align: "right" },
];
export default function DataTableEmptyExample() {
const [visits, setVisits] = useState<Visit[]>([]);
return (
<div
className="w-full max-w-2xl self-start"
style={{ minHeight: PREVIEW_MIN_HEIGHT }}
>
<DataTable
columns={columns}
data={visits}
emptyState={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BotIcon />
</EmptyMedia>
<EmptyTitle>No crawler visits yet</EmptyTitle>
<EmptyDescription>
Add the tracking snippet to your site and AI crawlers show up
here as they read your pages.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={() => setVisits(SAMPLE_VISITS)} size="sm">
<PlusIcon />
Add snippet
</Button>
</EmptyContent>
</Empty>
}
getRowId={(row) => row.id}
height={EMPTY_HEIGHT}
/>
</div>
);
}
<DataTable
columns={columns}
data={visits}
height={300}
emptyState={
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<BotIcon />
</EmptyMedia>
<EmptyTitle>No crawler visits yet</EmptyTitle>
<EmptyDescription>Add the tracking snippet to your site.</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm">Add snippet</Button>
</EmptyContent>
</Empty>
}
/>
height sizes the empty state; once rows arrive, the table shrinks to fit them.
Toolbar
toolbar puts a row above the column headers on its own white card, for summaries or filters that belong to the table.
"use client";
import { cn } from "cn";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { DataTable, type TableColumn } from "../components/data-table";
interface Change {
id: string;
kind: "gained" | "lost" | "improved";
engine: string;
prompt: string;
position: string;
}
interface SummaryGroup {
label: string;
up: number;
down: number;
}
const CHANGES: Change[] = [
{
id: "change-1",
kind: "gained",
engine: "ChatGPT",
prompt: "best AI meeting notes app for remote teams",
position: "#3",
},
{
id: "change-2",
kind: "lost",
engine: "Gemini",
prompt: "alternatives to Quillboard for startups",
position: "–",
},
{
id: "change-3",
kind: "improved",
engine: "Perplexity",
prompt: "tools that summarize Zoom calls",
position: "#5 → #2",
},
{
id: "change-4",
kind: "lost",
engine: "Claude",
prompt: "privacy-friendly note taker for EU companies",
position: "–",
},
];
const SUMMARY: SummaryGroup[] = [
{ label: "Mentions", up: 1, down: 2 },
{ label: "Position", up: 3, down: 0 },
];
const KIND_LABELS: Record<Change["kind"], string> = {
gained: "Mention gained",
lost: "Mention lost",
improved: "Position up",
};
const columns: TableColumn<Change>[] = [
{
key: "kind",
header: "Change",
width: "10rem",
cell: (row) => (
<span
className={cn(
"font-medium",
row.kind === "lost"
? "text-red-600 dark:text-red-400"
: "text-emerald-600 dark:text-emerald-400"
)}
>
{KIND_LABELS[row.kind]}
</span>
),
},
{ key: "engine", header: "Engine", width: "8rem", collapsePriority: 1 },
{ key: "prompt", header: "Prompt", width: "2fr" },
{ key: "position", header: "Position", width: "7rem", align: "right" },
];
function SummaryToolbar() {
return (
<div className="flex min-h-11 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 text-sm">
{SUMMARY.map((group, index) => (
<div className="flex items-center gap-4" key={group.label}>
{index > 0 ? (
<span aria-hidden="true" className="bg-border h-3 w-px" />
) : null}
<span className="flex items-center gap-2">
<span className="text-muted-foreground">{group.label}</span>
{group.up > 0 ? (
<span className="flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 text-emerald-700 tabular-nums dark:text-emerald-400">
<ChevronUpIcon className="size-3.5" />
{group.up}
</span>
) : null}
{group.down > 0 ? (
<span className="flex items-center gap-0.5 rounded-md bg-red-500/10 px-1.5 text-red-700 tabular-nums dark:text-red-400">
<ChevronDownIcon className="size-3.5" />
{group.down}
</span>
) : null}
</span>
</div>
))}
</div>
);
}
export default function DataTableToolbarExample() {
return (
<div className="w-full max-w-3xl self-start">
<DataTable
columns={columns}
data={CHANGES}
getRowId={(row) => row.id}
toolbar={<SummaryToolbar />}
/>
</div>
);
}
<DataTable
columns={columns}
data={changes}
toolbar={<SummaryToolbar />}
/>
Labels
Every string the table renders comes from labels, in English by default. Pass the ones you want to translate:
<DataTable
labels={{
pageOf: (page, total) => `Seite ${page} von ${total}`,
showRows: (count) => `${count} anzeigen`,
}}
/>
Table
The shadcn Table primitives get the same frame, for small static tables that don’t need sorting or paging.
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const INVOICES = [
{ id: "INV-1042", date: "Oct 1, 2026", plan: "Growth", amount: "$250.00" },
{ id: "INV-1018", date: "Sep 1, 2026", plan: "Growth", amount: "$250.00" },
{ id: "INV-0995", date: "Aug 1, 2026", plan: "Starter", amount: "$100.00" },
] as const;
export default function TablePrimitivesExample() {
return (
<div className="w-full max-w-2xl p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Plan</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell>
<span className="font-medium">{invoice.id}</span>
</TableCell>
<TableCell>{invoice.date}</TableCell>
<TableCell>{invoice.plan}</TableCell>
<TableCell className="text-right">{invoice.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}