# Data table

> A customers table with status filter tabs, search, row selection with select-all and paging. Works on any rows you pass. Renders in any Satori UI design system (cueit, skita, folio or a generated one): install a system first.

A shadcn/ui block from the [@satoriui](https://satoriui.site/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/satoriui/data-table-01).

- Page, with a live preview: https://shadcn.exchange/r/satoriui/data-table-01
- Screenshot: https://shadcn.exchange/shot/satoriui/data-table-01/light.png
- Registry item: https://satoriui.site/r/data-table-01.json
- Listed: 2026-11-04, source last changed 2026-11-04

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/satoriui/data-table-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `avatar`, `badge`, `button`, `card`, `checkbox`, `input`, `table`, `tabs`

## Files

### `design-systems/blocks/data-table-01.tsx`

```tsx
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Download, Search, UserPlus } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

/**
 * A customers table with status filters, search, row selection and paging.
 *
 * Written against the block contract only, so it takes the look of
 * whichever design system scopes it. Filtering, search and paging run on
 * the client over the rows you pass; for server data, keep the markup and
 * drive `rows` from your query instead.
 */

export type CustomerStatus = "Active" | "Trial" | "Past due" | "Cancelled";

export type CustomerRow = {
  id: string;
  name: string;
  email: string;
  status: CustomerStatus;
  plan: string;
  mrr: number;
  joined: string;
};

const TONE: Record<CustomerStatus, string> = {
  Active: "tint-success",
  Trial: "tint-info",
  "Past due": "tint-warning",
  Cancelled: "tint-danger",
};

const FILTERS = ["All", "Active", "Trial", "Past due", "Cancelled"] as const;
type Filter = (typeof FILTERS)[number];

const SAMPLE: CustomerRow[] = [
  { id: "c_01", name: "Lumen Labs", email: "ops@lumenlabs.io", status: "Active", plan: "Growth", mrr: 490, joined: "Mar 2024" },
  { id: "c_02", name: "Fieldnote", email: "hello@fieldnote.app", status: "Trial", plan: "Starter", mrr: 0, joined: "Nov 2025" },
  { id: "c_03", name: "Harbor & Co", email: "billing@harbor.co", status: "Past due", plan: "Growth", mrr: 490, joined: "Jan 2025" },
  { id: "c_04", name: "Quill Studio", email: "team@quill.studio", status: "Cancelled", plan: "Starter", mrr: 0, joined: "Jun 2024" },
  { id: "c_05", name: "Parallel", email: "finance@parallel.dev", status: "Active", plan: "Scale", mrr: 1490, joined: "Aug 2023" },
  { id: "c_06", name: "Moss & Stone", email: "hi@mossandstone.com", status: "Active", plan: "Starter", mrr: 49, joined: "Sep 2025" },
  { id: "c_07", name: "Kiln", email: "accounts@kiln.fm", status: "Trial", plan: "Growth", mrr: 0, joined: "Nov 2025" },
  { id: "c_08", name: "Northlight", email: "ap@northlight.xyz", status: "Active", plan: "Growth", mrr: 490, joined: "Feb 2025" },
  { id: "c_09", name: "Tessellate", email: "ops@tessellate.ai", status: "Past due", plan: "Scale", mrr: 1490, joined: "Apr 2024" },
];

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

const initials = (name: string) =>
  name
    .split(/\s+/)
    .filter((w) => /[a-z]/i.test(w))
    .slice(0, 2)
    .map((w) => w[0]!.toUpperCase())
    .join("");

export type DataTable01Props = React.ComponentProps<"div"> & {
  rows?: CustomerRow[];
  /** Rows per page. */
  pageSize?: number;
};

export function DataTable01({ rows = SAMPLE, pageSize = 6, className, ...props }: DataTable01Props) {
  const [filter, setFilter] = React.useState<Filter>("All");
  const [query, setQuery] = React.useState("");
  const [page, setPage] = React.useState(0);
  const [selected, setSelected] = React.useState<Set<string>>(() => new Set());

  const visible = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    return rows.filter(
      (r) =>
        (filter === "All" || r.status === filter) &&
        (!q || r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)),
    );
  }, [rows, filter, query]);

  const pages = Math.max(1, Math.ceil(visible.length / pageSize));
  const current = Math.min(page, pages - 1);
  const pageRows = visible.slice(current * pageSize, current * pageSize + pageSize);
  const allOnPage = pageRows.length > 0 && pageRows.every((r) => selected.has(r.id));
  const someOnPage = pageRows.some((r) => selected.has(r.id));

  const toggle = (id: string, on: boolean) =>
    setSelected((prev) => {
      const next = new Set(prev);
      if (on) next.add(id);
      else next.delete(id);
      return next;
    });

  const togglePage = (on: boolean) =>
    setSelected((prev) => {
      const next = new Set(prev);
      for (const r of pageRows) {
        if (on) next.add(r.id);
        else next.delete(r.id);
      }
      return next;
    });

  return (
    <div className={cn("@container/table w-full", className)} {...props}>
      <Card className="gap-0 py-0">
        <CardHeader className="border-b py-4">
          <CardTitle>Customers</CardTitle>
          <CardDescription>
            {rows.length} accounts, {rows.filter((r) => r.status === "Active").length} paying.
          </CardDescription>
          <CardAction className="flex gap-2">
            <Button variant="outline" size="sm" className="hidden @md/table:inline-flex">
              <Download />
              Export
            </Button>
            <Button size="sm">
              <UserPlus />
              Add customer
            </Button>
          </CardAction>
        </CardHeader>

        <div className="flex flex-col gap-3 border-b px-4 py-3 @2xl/table:flex-row @2xl/table:items-center @2xl/table:justify-between">
          <Tabs
            value={filter}
            onValueChange={(v) => {
              setFilter(v as Filter);
              setPage(0);
            }}
            className="max-w-full overflow-x-auto"
          >
            <TabsList>
              {FILTERS.map((f) => (
                <TabsTrigger key={f} value={f}>
                  {f}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
          <label className="relative flex items-center @2xl/table:w-64">
            <span className="sr-only">Search customers</span>
            <Search aria-hidden="true" className="pointer-events-none absolute left-2.5 size-3.5 text-muted-foreground" />
            <Input
              type="search"
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setPage(0);
              }}
              placeholder="Search name or email"
              className="h-8 pl-8"
            />
          </label>
        </div>

        <CardContent className="px-2">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead className="w-8">
                  <Checkbox
                    aria-label="Select all on this page"
                    checked={allOnPage ? true : someOnPage ? "indeterminate" : false}
                    onCheckedChange={(v) => togglePage(v === true)}
                  />
                </TableHead>
                <TableHead>Customer</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="hidden @xl/table:table-cell">Plan</TableHead>
                <TableHead className="hidden @2xl/table:table-cell">Joined</TableHead>
                <TableHead className="text-right">MRR</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {pageRows.map((r) => {
                const isSelected = selected.has(r.id);
                return (
                  <TableRow key={r.id} data-state={isSelected ? "selected" : undefined}>
                    <TableCell>
                      <Checkbox
                        aria-label={`Select ${r.name}`}
                        checked={isSelected}
                        onCheckedChange={(v) => toggle(r.id, v === true)}
                      />
                    </TableCell>
                    <TableCell>
                      <span className="flex min-w-0 items-center gap-2.5">
                        <Avatar className="size-7">
                          <AvatarFallback>{initials(r.name)}</AvatarFallback>
                        </Avatar>
                        <span className="min-w-0">
                          <span className="block truncate font-medium">{r.name}</span>
                          <span className="ds-meta block truncate">{r.email}</span>
                        </span>
                      </span>
                    </TableCell>
                    <TableCell>
                      <Badge variant="outline" className={TONE[r.status]}>
                        {r.status}
                      </Badge>
                    </TableCell>
                    <TableCell className="hidden @xl/table:table-cell">{r.plan}</TableCell>
                    <TableCell className="ds-meta hidden @2xl/table:table-cell">{r.joined}</TableCell>
                    <TableCell className="text-right tabular-nums">{r.mrr ? money.format(r.mrr) : "—"}</TableCell>
                  </TableRow>
                );
              })}
              {pageRows.length === 0 && (
                <TableRow>
                  <TableCell colSpan={6} className="ds-small h-28 text-center text-muted-foreground">
                    No customers match “{query || filter}”.
                  </TableCell>
                </TableRow>
              )}
            </TableBody>
          </Table>
        </CardContent>

        <div className="flex items-center justify-between gap-3 border-t px-4 py-3">
          <p className="ds-small text-muted-foreground" aria-live="polite">
            {selected.size > 0 ? `${selected.size} selected` : `${visible.length} shown`}
          </p>
          <div className="flex items-center gap-2">
            <span className="ds-meta">
              Page {current + 1} of {pages}
            </span>
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Previous page"
              disabled={current === 0}
              onClick={() => setPage(current - 1)}
            >
              <ChevronLeft />
            </Button>
            <Button
              variant="outline"
              size="icon-sm"
              aria-label="Next page"
              disabled={current >= pages - 1}
              onClick={() => setPage(current + 1)}
            >
              <ChevronRight />
            </Button>
          </div>
        </div>
      </Card>
    </div>
  );
}
```
