Skip to content
Notra logoNotra UI
Esc
↑↓navigate↵open⌘Jpreview
On this page

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-table

This 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>
  );
}