DataTable

free

DataTable pairs TanStack Table with the product Table primitives so sorting, selection, and paging arrive already wired.

@tanstack/react-table
Preview
Dark
Name
Email
Plan
Status
Aria Patelaria@acme.ioGrowthActive
Diego Ramosdiego@acme.ioScaleActive
Mei Linmei@acme.ioStarterTrial
Noah Beckernoah@acme.ioGrowthPast due
Priya Nairpriya@acme.ioScaleActive
Lucas Ferreiralucas@acme.ioStarterTrial
Hana Satohana@acme.ioGrowthActive
Owen Clarkeowen@acme.ioStarterActive
Freya Larsenfreya@acme.ioScalePast due
Malik Johnsonmalik@acme.ioGrowthActive
    import { useMemo, useState } from 'react';
    import DataTable from '@/components/composites/DataTable';
    import Tag from '@/components/ui/Tag';
    import type { ColumnDef, OnSortParam } from '@/components/composites/DataTable';
    
    type Customer = {
      id: string;
      name: string;
      email: string;
      plan: 'Starter' | 'Growth' | 'Scale';
      status: 'Active' | 'Trial' | 'Past due';
    };
    
    const customers: Customer[] = [
      { id: 'c1', name: 'Aria Patel', email: 'aria@acme.io', plan: 'Growth', status: 'Active' },
      { id: 'c2', name: 'Diego Ramos', email: 'diego@acme.io', plan: 'Scale', status: 'Active' },
      { id: 'c3', name: 'Mei Lin', email: 'mei@acme.io', plan: 'Starter', status: 'Trial' },
      { id: 'c4', name: 'Noah Becker', email: 'noah@acme.io', plan: 'Growth', status: 'Past due' },
      { id: 'c5', name: 'Priya Nair', email: 'priya@acme.io', plan: 'Scale', status: 'Active' },
      { id: 'c6', name: 'Lucas Ferreira', email: 'lucas@acme.io', plan: 'Starter', status: 'Trial' },
      { id: 'c7', name: 'Hana Sato', email: 'hana@acme.io', plan: 'Growth', status: 'Active' },
      { id: 'c8', name: 'Owen Clarke', email: 'owen@acme.io', plan: 'Starter', status: 'Active' },
      { id: 'c9', name: 'Freya Larsen', email: 'freya@acme.io', plan: 'Scale', status: 'Past due' },
      { id: 'c10', name: 'Malik Johnson', email: 'malik@acme.io', plan: 'Growth', status: 'Active' },
      { id: 'c11', name: 'Ines Duarte', email: 'ines@acme.io', plan: 'Starter', status: 'Trial' },
      { id: 'c12', name: 'Sana Malik', email: 'sana@acme.io', plan: 'Scale', status: 'Active' },
    ];
    
    const statusIntent: Record<Customer['status'], string> = {
      Active: 'bg-success',
      Trial: 'bg-info',
      'Past due': 'bg-destructive',
    };
    
    const columns: ColumnDef<Customer>[] = [
      { accessorKey: 'name', header: 'Name' },
      { accessorKey: 'email', header: 'Email', enableSorting: false },
      {
        accessorKey: 'plan',
        header: 'Plan',
        enableSorting: false,
        cell: ({ row }) => <Tag className="bg-card">{row.original.plan}</Tag>,
      },
      {
        accessorKey: 'status',
        header: 'Status',
        enableSorting: false,
        cell: ({ row }) => (
          <Tag
            prefix
            prefixClass={statusIntent[row.original.status]}
            className="bg-card"
          >
            {row.original.status}
          </Tag>
        ),
      },
    ];
    
    export default function UsageDemo() {
      const [pageIndex, setPageIndex] = useState(1);
      const [pageSize, setPageSize] = useState(10);
      const [sort, setSort] = useState<OnSortParam>({ sortOrder: '', sortKey: '' });
    
      const sorted = useMemo(() => {
        if (!sort.sortKey) return customers;
        const key = sort.sortKey as keyof Customer;
        return [...customers].sort((a, b) => {
          const diff = String(a[key]).localeCompare(String(b[key]));
          return sort.sortOrder === 'desc' ? -diff : diff;
        });
      }, [sort]);
    
      const start = (pageIndex - 1) * pageSize;
      const pageRows = sorted.slice(start, start + pageSize);
    
      return (
        <DataTable<Customer>
          overflowClass="border rounded-card"
          columns={columns}
          data={pageRows}
          pagingData={{ total: customers.length, pageIndex, pageSize }}
          onPaginationChange={setPageIndex}
          onPageSizeChange={(size) => {
            setPageSize(size);
            setPageIndex(1);
          }}
          onSort={(nextSort) => {
            setSort(nextSort);
            setPageIndex(1);
          }}
        />
      );
    }

    Installation

    Add this component with the NateUI CLI.

    npx nateui@latest add DataTable

    Examples

    Sorting

    onSort receives the clicked column's key and direction; DataTable expects the parent to sort data before passing it back in.

    Preview
    Dark
    Deal
    Stage
    Value
    Close date
    Fenwick LabsNegotiation$48,0002026-07-18
    Bright Harbor CoProposal$22,5002026-07-25
    Northgate RetailDiscovery$9,8002026-08-02
    Alderwood GroupClosed Won$63,0002026-06-30
    Pinecrest StudioNegotiation$31,5002026-07-21
    Solace HealthProposal$17,2002026-08-09
      import { useMemo, useState } from 'react';
      import DataTable from '@/components/composites/DataTable';
      import Tag from '@/components/ui/Tag';
      import type { ColumnDef, OnSortParam } from '@/components/composites/DataTable';
      
      type Deal = {
        id: string;
        name: string;
        stage: 'Discovery' | 'Proposal' | 'Negotiation' | 'Closed Won';
        value: number;
        closeDate: string;
      };
      
      const deals: Deal[] = [
        { id: 'd1', name: 'Fenwick Labs', stage: 'Negotiation', value: 48000, closeDate: '2026-07-18' },
        { id: 'd2', name: 'Bright Harbor Co', stage: 'Proposal', value: 22500, closeDate: '2026-07-25' },
        { id: 'd3', name: 'Northgate Retail', stage: 'Discovery', value: 9800, closeDate: '2026-08-02' },
        { id: 'd4', name: 'Alderwood Group', stage: 'Closed Won', value: 63000, closeDate: '2026-06-30' },
        { id: 'd5', name: 'Pinecrest Studio', stage: 'Negotiation', value: 31500, closeDate: '2026-07-21' },
        { id: 'd6', name: 'Solace Health', stage: 'Proposal', value: 17200, closeDate: '2026-08-09' },
      ];
      
      const stageIntent: Record<Deal['stage'], string | undefined> = {
        Discovery: undefined,
        Proposal: 'bg-info',
        Negotiation: 'bg-warning',
        'Closed Won': 'bg-success',
      };
      
      const columns: ColumnDef<Deal>[] = [
        { accessorKey: 'name', header: 'Deal' },
        {
          accessorKey: 'stage',
          header: 'Stage',
          enableSorting: false,
          cell: ({ row }) => (
            <Tag prefix prefixClass={stageIntent[row.original.stage]} className="bg-card">
              {row.original.stage}
            </Tag>
          ),
        },
        {
          accessorKey: 'value',
          header: 'Value',
          cell: ({ row }) => `$${row.original.value.toLocaleString()}`,
        },
        { accessorKey: 'closeDate', header: 'Close date' },
      ];
      
      export default function SortingDemo() {
        const [sort, setSort] = useState<OnSortParam>({ sortOrder: '', sortKey: '' });
      
        const sorted = useMemo(() => {
          if (!sort.sortKey) return deals;
          const key = sort.sortKey as keyof Deal;
          return [...deals].sort((a, b) => {
            const diff =
              typeof a[key] === 'number' && typeof b[key] === 'number'
                ? a[key] - b[key]
                : String(a[key]).localeCompare(String(b[key]));
            return sort.sortOrder === 'desc' ? -diff : diff;
          });
        }, [sort]);
      
        return (
          <DataTable<Deal>
            overflowClass="border rounded-card"
            columns={columns}
            data={sorted}
            pagingData={{ total: deals.length, pageIndex: 1, pageSize: 10 }}
            onSort={setSort}
          />
        );
      }

      Row Selection

      selectable adds a checkbox column; onRowSelect and onAllRowSelect report which rows are checked so a parent can drive bulk actions.

      Preview
      Dark
      Ticket
      Subject
      Priority
      Assignee
      TCK-201Checkout button unresponsiveHighAria Patel
      TCK-198Export CSV missing a columnMediumDiego Ramos
      TCK-195Typo in confirmation emailLowMei Lin
      TCK-190Slow load on reports pageMediumAria Patel
      TCK-187Dark mode toggle resets on reloadMediumOwen Clarke
        import { useState } from 'react';
        import DataTable from '@/components/composites/DataTable';
        import Button from '@/components/ui/Button';
        import Tag from '@/components/ui/Tag';
        import type { ColumnDef, Row } from '@/components/composites/DataTable';
        
        type Ticket = {
          id: string;
          subject: string;
          priority: 'Low' | 'Medium' | 'High';
          assignee: string;
        };
        
        const initialTickets: Ticket[] = [
          { id: 'TCK-201', subject: 'Checkout button unresponsive', priority: 'High', assignee: 'Aria Patel' },
          { id: 'TCK-198', subject: 'Export CSV missing a column', priority: 'Medium', assignee: 'Diego Ramos' },
          { id: 'TCK-195', subject: 'Typo in confirmation email', priority: 'Low', assignee: 'Mei Lin' },
          { id: 'TCK-190', subject: 'Slow load on reports page', priority: 'Medium', assignee: 'Aria Patel' },
          { id: 'TCK-187', subject: 'Dark mode toggle resets on reload', priority: 'Medium', assignee: 'Owen Clarke' },
        ];
        
        const columns: ColumnDef<Ticket>[] = [
          { accessorKey: 'id', header: 'Ticket' },
          { accessorKey: 'subject', header: 'Subject' },
          {
            accessorKey: 'priority',
            header: 'Priority',
            cell: ({ row }) => (
              <Tag
                prefix
                prefixClass={
                  row.original.priority === 'High'
                    ? 'bg-destructive'
                    : row.original.priority === 'Medium'
                      ? 'bg-warning'
                      : undefined
                }
                className="bg-card"
              >
                {row.original.priority}
              </Tag>
            ),
          },
          { accessorKey: 'assignee', header: 'Assignee' },
        ];
        
        export default function RowSelectionDemo() {
          const [tickets, setTickets] = useState(initialTickets);
          const [selected, setSelected] = useState<Ticket[]>([]);
        
          const handleRowSelect = (checked: boolean, row: Ticket) => {
            setSelected((prev) =>
              checked ? [...prev, row] : prev.filter((ticket) => ticket.id !== row.id),
            );
          };
        
          const handleAllRowSelect = (checked: boolean, rows: Row<Ticket>[]) => {
            setSelected(checked ? rows.map((row) => row.original) : []);
          };
        
          const handleArchive = () => {
            const archivedIds = new Set(selected.map((ticket) => ticket.id));
            setTickets((prev) => prev.filter((ticket) => !archivedIds.has(ticket.id)));
            setSelected([]);
          };
        
          return (
            <div className="flex flex-col gap-2 w-full">
              {selected.length > 0 && (
                <div className="flex items-center justify-between gap-4 rounded-card border bg-card px-4 py-2">
                  <span className="text-sm text-muted-foreground">
                    {selected.length} ticket{selected.length > 1 ? 's' : ''} selected
                  </span>
                  <Button size="sm" variant="solid" onClick={handleArchive}>
                    Archive selected
                  </Button>
                </div>
              )}
              <DataTable<Ticket>
                selectable
                overflowClass="border rounded-card"
                columns={columns}
                data={tickets}
                pagingData={{ total: tickets.length, pageIndex: 1, pageSize: 10 }}
                onRowSelect={handleRowSelect}
                onAllRowSelect={handleAllRowSelect}
              />
            </div>
          );
        }

        Column Resizing

        columnResize adds resize handles to DataTable's default header cells. Column size, minSize, and maxSize still come from the column definitions.

        Preview
        Dark
        SKU
        Product
        Category
        Price
        Stock
        WM-1001Wireless MousePeripherals$29.00184
        MK-2044Mechanical KeyboardPeripherals$89.0062
        UM-3320USB-C Monitor ArmAccessories$54.0041
        DK-4108Standing Desk ConverterFurniture$210.0015
        HP-5061Noise-Cancelling HeadsetAudio$135.0073
          import DataTable from '@/components/composites/DataTable';
          import type { ColumnDef } from '@/components/composites/DataTable';
          
          type Product = {
            id: string;
            sku: string;
            name: string;
            category: string;
            price: string;
            stock: number;
          };
          
          const products: Product[] = [
            { id: 'p1', sku: 'WM-1001', name: 'Wireless Mouse', category: 'Peripherals', price: '$29.00', stock: 184 },
            { id: 'p2', sku: 'MK-2044', name: 'Mechanical Keyboard', category: 'Peripherals', price: '$89.00', stock: 62 },
            { id: 'p3', sku: 'UM-3320', name: 'USB-C Monitor Arm', category: 'Accessories', price: '$54.00', stock: 41 },
            { id: 'p4', sku: 'DK-4108', name: 'Standing Desk Converter', category: 'Furniture', price: '$210.00', stock: 15 },
            { id: 'p5', sku: 'HP-5061', name: 'Noise-Cancelling Headset', category: 'Audio', price: '$135.00', stock: 73 },
          ];
          
          const columns: ColumnDef<Product>[] = [
            { accessorKey: 'sku', header: 'SKU', size: 110, minSize: 90 },
            { accessorKey: 'name', header: 'Product', size: 220, minSize: 180 },
            { accessorKey: 'category', header: 'Category', size: 140, minSize: 120 },
            { accessorKey: 'price', header: 'Price', size: 100, minSize: 90 },
            { accessorKey: 'stock', header: 'Stock', size: 90, minSize: 80 },
          ];
          
          export default function ColumnResizingDemo() {
            return (
              <DataTable<Product>
                overflowClass="border rounded-card"
                columns={columns}
                data={products}
                columnResize={{ mode: 'onChange' }}
                pagingData={{ total: products.length, pageIndex: 1, pageSize: 10 }}
              />
            );
          }

          Pinned Columns

          columnPinning sticks columns in DataTable's default renderer. Pin by column id, then use column size values to keep the start and end offsets stable. When selectable is also enabled, DataTable pins its checkbox first at the logical start, before consumer-declared start-pinned columns.

          Preview
          Dark
          Name
          Email
          Department
          Location
          Manager
          Start date
          Aria Patelaria@acme.ioEngineeringRemoteNoah Becker2023-04-11
          Diego Ramosdiego@acme.ioDesignAustin, TXMei Lin2022-09-02
          Priya Nairpriya@acme.ioSupportRemoteOwen Clarke2024-01-19
          Lucas Ferreiralucas@acme.ioSalesNew York, NYFreya Larsen2023-11-06
          Hana Satohana@acme.ioEngineeringRemoteNoah Becker2021-06-24
            import DataTable from '@/components/composites/DataTable';
            import Button from '@/components/ui/Button';
            import type { ColumnDef } from '@/components/composites/DataTable';
            
            type Employee = {
              id: string;
              name: string;
              email: string;
              department: string;
              location: string;
              manager: string;
              startDate: string;
            };
            
            const employees: Employee[] = [
              { id: 'e1', name: 'Aria Patel', email: 'aria@acme.io', department: 'Engineering', location: 'Remote', manager: 'Noah Becker', startDate: '2023-04-11' },
              { id: 'e2', name: 'Diego Ramos', email: 'diego@acme.io', department: 'Design', location: 'Austin, TX', manager: 'Mei Lin', startDate: '2022-09-02' },
              { id: 'e3', name: 'Priya Nair', email: 'priya@acme.io', department: 'Support', location: 'Remote', manager: 'Owen Clarke', startDate: '2024-01-19' },
              { id: 'e4', name: 'Lucas Ferreira', email: 'lucas@acme.io', department: 'Sales', location: 'New York, NY', manager: 'Freya Larsen', startDate: '2023-11-06' },
              { id: 'e5', name: 'Hana Sato', email: 'hana@acme.io', department: 'Engineering', location: 'Remote', manager: 'Noah Becker', startDate: '2021-06-24' },
            ];
            
            const columns: ColumnDef<Employee>[] = [
              { accessorKey: 'name', header: 'Name', size: 160 },
              { accessorKey: 'email', header: 'Email', size: 200 },
              { accessorKey: 'department', header: 'Department', size: 150 },
              { accessorKey: 'location', header: 'Location', size: 150 },
              { accessorKey: 'manager', header: 'Manager', size: 150 },
              { accessorKey: 'startDate', header: 'Start date', size: 130 },
              {
                id: 'actions',
                header: '',
                size: 90,
                cell: () => (
                  <Button size="sm">
                    View
                  </Button>
                ),
              },
            ];
            
            export default function PinnedColumnsDemo() {
              return (
                <DataTable<Employee>
                  overflowClass="border rounded-card"
                  columns={columns}
                  data={employees}
                  columnPinning={{ start: ['name'], end: ['actions'] }}
                  pagingData={{ total: employees.length, pageIndex: 1, pageSize: 10 }}
                />
              );
            }

            Expandable Rows

            Rows carry their own depth and parentId; collapsing a folder filters its descendants out of the visible list, so this uses DataTable's default renderer directly.

            Preview
            Dark
            Name
            Type
            Size
            Modified
            Brand Kit
            Folder2 itemsJun 2, 2026
            Logo Variants
            Folder2 itemsMay 18, 2026
            Primary Logo.png
            Image860 KBMay 12, 2026
            Logo Guidelines.pdf
            Document1.4 MBMay 18, 2026
            Brand Voice.md
            Text9 KBJun 2, 2026
            Product Screenshots
            Folder2 itemsJun 20, 2026
            dashboard-dark.png
            Image2.1 MBJun 14, 2026
            dashboard-light.png
            Image2.3 MBJun 20, 2026
            changelog.txt
            Text6 KBJun 28, 2026
              import { useMemo, useState } from 'react';
              import DataTable from '@/components/composites/DataTable';
              import Tag from '@/components/ui/Tag';
              import {
                  PiCaretRight,
                  PiFile,
                  PiFolder,
                  PiFolderOpen,
                  PiImage,
              } from 'react-icons/pi'
              import type { ColumnDef } from '@/components/composites/DataTable';
              
              type FileType = 'folder' | 'document' | 'text' | 'image';
              
              type FileNode = {
                id: string;
                name: string;
                type: FileType;
                size: string;
                modified: string;
                depth: number;
                parentId: string | null;
              };
              
              const nodes: FileNode[] = [
                { id: 'brand-kit', name: 'Brand Kit', type: 'folder', size: '2 items', modified: 'Jun 2, 2026', depth: 0, parentId: null },
                { id: 'logo-variants', name: 'Logo Variants', type: 'folder', size: '2 items', modified: 'May 18, 2026', depth: 1, parentId: 'brand-kit' },
                { id: 'primary-logo', name: 'Primary Logo.png', type: 'image', size: '860 KB', modified: 'May 12, 2026', depth: 2, parentId: 'logo-variants' },
                { id: 'logo-guidelines', name: 'Logo Guidelines.pdf', type: 'document', size: '1.4 MB', modified: 'May 18, 2026', depth: 2, parentId: 'logo-variants' },
                { id: 'brand-voice', name: 'Brand Voice.md', type: 'text', size: '9 KB', modified: 'Jun 2, 2026', depth: 1, parentId: 'brand-kit' },
                { id: 'product-screenshots', name: 'Product Screenshots', type: 'folder', size: '2 items', modified: 'Jun 20, 2026', depth: 0, parentId: null },
                { id: 'dashboard-dark', name: 'dashboard-dark.png', type: 'image', size: '2.1 MB', modified: 'Jun 14, 2026', depth: 1, parentId: 'product-screenshots' },
                { id: 'dashboard-light', name: 'dashboard-light.png', type: 'image', size: '2.3 MB', modified: 'Jun 20, 2026', depth: 1, parentId: 'product-screenshots' },
                { id: 'changelog', name: 'changelog.txt', type: 'text', size: '6 KB', modified: 'Jun 28, 2026', depth: 0, parentId: null },
              ];
              
              const nodesById = new Map(nodes.map((node) => [node.id, node]));
              
              const typeStyles: Record<FileType, { tagClassName: string; label: string }> = {
                folder: { 
                  tagClassName: 'text-palette-yellow-soft-foreground bg-palette-yellow-soft',
                  label: 'Folder' 
                },
                document: { 
                  tagClassName: 'text-palette-blue-soft-foreground bg-palette-blue-soft',
                  label: 'Document' 
                },
                text: { 
                  tagClassName: 'text-palette-cyan-soft-foreground bg-palette-cyan-soft',
                  label: 'Text' 
                },
                image: { 
                  tagClassName: 'text-palette-purple-soft-foreground bg-palette-purple-soft',
                  label: 'Image' 
                },
              };
              
              function isVisible(node: FileNode, expanded: Set<string>): boolean {
                let current = node;
                while (current.parentId) {
                  const parent = nodesById.get(current.parentId);
                  if (!parent || !expanded.has(current.parentId)) return false;
                  current = parent;
                }
                return true;
              }
              
              export default function ExpandableRowsDemo() {
                const [expanded, setExpanded] = useState<Set<string>>(
                  new Set(['brand-kit', 'logo-variants', 'product-screenshots']),
                );
              
                const toggle = (id: string) => {
                  setExpanded((prev) => {
                    const next = new Set(prev);
                    if (next.has(id)) {
                      next.delete(id);
                    } else {
                      next.add(id);
                    }
                    return next;
                  });
                };
              
                const visibleNodes = useMemo(
                  () => nodes.filter((node) => isVisible(node, expanded)),
                  [expanded],
                );
              
                const columns: ColumnDef<FileNode>[] = useMemo(
                  () => [
                    {
                      accessorKey: 'name',
                      header: 'Name',
                      enableSorting: false,
                      size: 280,
                      cell: ({ row }) => {
                        const node = row.original;
                        const isFolder = node.type === 'folder';
                        const isOpen = expanded.has(node.id);
                        return (
                          <div className="flex items-center gap-2" style={{ paddingLeft: node.depth * 20 }}>
                            {isFolder ? (
                              <button
                                type="button"
                                aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
                                className="inline-flex cursor-pointer text-muted-foreground"
                                onClick={() => toggle(node.id)}
                              >
                                <PiCaretRight className={isOpen ? 'rotate-90 transition' : 'transition'} />
                              </button>
                            ) : (
                              <span className="inline-block w-4" />
                            )}
                            {isFolder ? (
                              isOpen ? (
                                <PiFolderOpen />
                              ) : (
                                <PiFolder />
                              )
                            ) : node.type === 'image' ? (
                              <PiImage />
                            ) : (
                              <PiFile />
                            )}
                            <span className={isFolder ? 'font-medium text-foreground' : 'text-foreground'}>
                              {node.name}
                            </span>
                          </div>
                        );
                      },
                    },
                    {
                      accessorKey: 'type',
                      header: 'Type',
                      enableSorting: false,
                      size: 120,
                      cell: ({ row }) => {
                        const style = typeStyles[row.original.type];
                        return (
                          <Tag
                            className={`${style.tagClassName} border-0`}
                          >
                            {style.label}
                          </Tag>
                        );
                      },
                    },
                    { accessorKey: 'size', header: 'Size', enableSorting: false, size: 100 },
                    { accessorKey: 'modified', header: 'Modified', enableSorting: false, size: 130 },
                  ],
                  [expanded],
                );
              
                return (
                  <DataTable<FileNode>
                    overflowClass="border rounded-card"
                    columns={columns}
                    data={visibleNodes}
                    pagingData={{ total: visibleNodes.length, pageIndex: 1, pageSize: 10 }}
                  />
                );
              }

              Drag And Drop

              Reordering rows needs the underlying TanStack table instance plus @dnd-kit. This example renders through DataTable's children render prop and uses Table's asElement escape hatch so sortable rows move as grid-backed blocks instead of native table rows.

              Preview
              Dark
              Feature request
              Votes
              Status
              Bulk CSV export
              128
              In progress
              Saved filter views
              96
              Planned
              Slack notifications
              74
              Planned
              Dark mode scheduling
              51
              Planned
              Audit log retention
              33
              Planned
                import { useMemo, useState } from 'react';
                import type { CSSProperties, ReactNode } from 'react';
                import DataTable from '@/components/composites/DataTable';
                import Table from '@/components/ui/Table';
                import Tag from '@/components/ui/Tag';
                import { flexRender } from '@tanstack/react-table';
                import {
                  DndContext,
                  closestCenter,
                  MouseSensor,
                  TouchSensor,
                  KeyboardSensor,
                  useSensor,
                  useSensors,
                } from '@dnd-kit/core';
                import type { DragEndEvent } from '@dnd-kit/core';
                import {
                  SortableContext,
                  sortableKeyboardCoordinates,
                  verticalListSortingStrategy,
                  useSortable,
                  arrayMove,
                } from '@dnd-kit/sortable';
                import { CSS } from '@dnd-kit/utilities';
                import { PiDotsSixVertical } from 'react-icons/pi'
                import type { ColumnDef } from '@/components/composites/DataTable';
                
                const { THead, TBody, Tr, Th, Td } = Table;
                
                type Feature = {
                  id: string;
                  title: string;
                  votes: number;
                  status: 'Planned' | 'In progress';
                };
                
                const initialFeatures: Feature[] = [
                  { id: 'f1', title: 'Bulk CSV export', votes: 128, status: 'In progress' },
                  { id: 'f2', title: 'Saved filter views', votes: 96, status: 'Planned' },
                  { id: 'f3', title: 'Slack notifications', votes: 74, status: 'Planned' },
                  { id: 'f4', title: 'Dark mode scheduling', votes: 51, status: 'Planned' },
                  { id: 'f5', title: 'Audit log retention', votes: 33, status: 'Planned' },
                ];
                
                const columns: ColumnDef<Feature>[] = [
                  { id: 'drag', header: '', size: 32 },
                  { accessorKey: 'title', header: 'Feature request' },
                  { accessorKey: 'votes', header: 'Votes' },
                  {
                    accessorKey: 'status',
                    header: 'Status',
                    cell: ({ row }) => (
                      <Tag
                        prefix
                        prefixClass={row.original.status === 'In progress' ? 'bg-info' : 'bg-muted'}
                        className="bg-card"
                      >
                        {row.original.status}
                      </Tag>
                    ),
                  },
                ];
                
                const rowGrid = 'grid grid-cols-[56px_minmax(220px,1fr)_110px_150px] items-center';
                
                type SortableRenderProps = Pick<
                  ReturnType<typeof useSortable>,
                  'attributes' | 'listeners' | 'setActivatorNodeRef' | 'isDragging'
                >;
                
                function SortableRow({
                  id,
                  label,
                  children,
                }: {
                  id: string;
                  label: string;
                  children: (props: SortableRenderProps) => ReactNode;
                }) {
                  const {
                    attributes,
                    listeners,
                    setActivatorNodeRef,
                    setNodeRef,
                    transform,
                    transition,
                    isDragging,
                  } = useSortable({ id });
                
                  const style: CSSProperties = {
                    transform: CSS.Translate.toString(transform),
                    transition,
                    position: 'relative',
                    zIndex: isDragging ? 1 : undefined,
                  };
                
                  return (
                    <Tr
                      asElement="div"
                      ref={setNodeRef}
                      style={style}
                      aria-label={label}
                      className={
                        isDragging
                          ? `${rowGrid} bg-card shadow-lg ring-1 ring-primary/30`
                          : rowGrid
                      }
                    >
                      {children({ attributes, listeners, setActivatorNodeRef, isDragging })}
                    </Tr>
                  );
                }
                
                export default function DragAndDropDemo() {
                  const [features, setFeatures] = useState(initialFeatures);
                  const featureIds = useMemo(
                    () => features.map((feature) => feature.id),
                    [features],
                  );
                  const sensors = useSensors(
                    useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
                    useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5 } }),
                    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
                  );
                
                  const handleDragEnd = (event: DragEndEvent) => {
                    const { active, over } = event;
                    if (!over || active.id === over.id) return;
                    setFeatures((prev) => {
                      const oldIndex = prev.findIndex((item) => item.id === active.id);
                      const newIndex = prev.findIndex((item) => item.id === over.id);
                      if (oldIndex < 0 || newIndex < 0) return prev;
                      return arrayMove(prev, oldIndex, newIndex);
                    });
                  };
                
                  return (
                    <DataTable<Feature>
                      columns={columns}
                      data={features}
                      pagingData={{ total: features.length, pageIndex: 1, pageSize: 10 }}
                    >
                      {({ table }) => (
                        <DndContext
                          id="data-table-drag-and-drop"
                          sensors={sensors}
                          collisionDetection={closestCenter}
                          onDragEnd={handleDragEnd}
                        >
                          <Table 
                            overflowClass="border rounded-card"
                            asElement="div" 
                            className="divide-x-0"
                          >
                            <THead asElement="div">
                              {table.getHeaderGroups().map((headerGroup) => (
                                <Tr key={headerGroup.id} asElement="div" className={rowGrid}>
                                  {headerGroup.headers.map((header) => (
                                    <Th key={header.id} asElement="div">
                                      {flexRender(header.column.columnDef.header, header.getContext())}
                                    </Th>
                                  ))}
                                </Tr>
                              ))}
                            </THead>
                            <SortableContext
                              items={featureIds}
                              strategy={verticalListSortingStrategy}
                            >
                              <TBody asElement="div">
                                {table.getRowModel().rows.map((row) => (
                                  <SortableRow
                                    key={row.original.id}
                                    id={row.original.id}
                                    label={`Reorder ${row.original.title}`}
                                  >
                                    {({ attributes, listeners, setActivatorNodeRef }) =>
                                      row.getVisibleCells().map((cell) => {
                                        if (cell.column.id === 'drag') {
                                          return (
                                            <Td
                                              key={cell.id}
                                              asElement="div"
                                              className="flex justify-center"
                                            >
                                              <button
                                                ref={setActivatorNodeRef}
                                                type="button"
                                                aria-label={`Reorder ${row.original.title}`}
                                                className="inline-flex cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
                                                {...attributes}
                                                {...listeners}
                                              >
                                                <PiDotsSixVertical aria-hidden />
                                              </button>
                                            </Td>
                                          );
                                        }
                                        return (
                                          <Td key={cell.id} asElement="div">
                                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
                                          </Td>
                                        );
                                      })
                                    }
                                  </SortableRow>
                                ))}
                              </TBody>
                            </SortableContext>
                          </Table>
                        </DndContext>
                      )}
                    </DataTable>
                  );
                }

                Empty State

                Set noData when the current result set has no rows. Use renderEmptyState to replace the built-in state with a complete EmptyState composition, including a custom illustration, supporting copy, and an action.

                Preview
                Dark
                Name
                Email

                No customers found

                Try adjusting your search or filters.

                  import EmptyState from '@/components/composites/EmptyState';
                  import DataTable from '@/components/composites/DataTable';
                  import Button from '@/components/ui/Button';
                  import type { ColumnDef } from '@/components/composites/DataTable';
                  
                  type Customer = {
                    id: string;
                    name: string;
                    email: string;
                  };
                  
                  const columns: ColumnDef<Customer>[] = [
                    { accessorKey: 'name', header: 'Name' },
                    { accessorKey: 'email', header: 'Email' },
                  ];
                  
                  const frontPanelGradientId = 'data-table-empty-state-front-panel-gradient';
                  
                  const SearchResultsIllustration = () => (
                    <svg
                      width="124"
                      height="86"
                      viewBox="0 0 124 86"
                      fill="none"
                      xmlns="http://www.w3.org/2000/svg"
                      aria-hidden="true"
                      focusable="false"
                    >
                      <defs>
                        <linearGradient
                          id={frontPanelGradientId}
                          x1="59.7644"
                          y1="8.3401"
                          x2="59.7644"
                          y2="69.1341"
                          gradientUnits="userSpaceOnUse"
                        >
                          <stop offset="0%" stopColor="var(--nui-card)" />
                          <stop offset="0.9964" stopColor="var(--nui-muted)" />
                        </linearGradient>
                      </defs>
                      <path
                        className="fill-muted-foreground/40"
                        d="M21.4994 0H93.5994C96.4994 0 98.6994 2.2 98.6994 5.1V50.8C98.6994 53.7 96.4994 55.9 93.5994 55.9H21.4994C18.5994 55.9 16.3994 53.7 16.3994 50.8V5.1C16.3994 2.2 18.7994 0 21.4994 0Z"
                      />
                      <path
                        className="fill-card"
                        d="M93.7996 16.7002H70.3996C68.0996 16.7002 65.8996 17.5002 64.1996 18.9002L58.0996 23.9002C56.3996 25.3002 54.1996 26.1002 51.8996 26.1002H25.4994C22.7994 26.1002 20.5996 28.3002 20.5996 31.0002C20.5996 31.2002 20.5996 31.5002 20.6996 31.7002L25.5996 57.5002C25.9996 59.9002 27.9996 61.7002 30.4996 61.7002H86.9996C89.3996 61.7002 91.4996 60.0002 91.8996 57.6002L98.6996 22.3002C99.1996 19.6002 97.3994 17.2002 94.6994 16.7002C94.2994 16.7002 93.9994 16.7002 93.7996 16.7002Z"
                      />
                      <path
                        className="stroke-border"
                        fill={`url(#${frontPanelGradientId})`}
                        strokeWidth="1"
                        d="M104.3 9.80019H73.9C70.9 9.80019 68.1 10.8002 65.8 12.7002L57.8 19.2002C55.6 21.0002 52.7 22.1002 49.7 22.1002H15.3C11.8 22.1002 9 25.0002 9 28.4002C9 28.7002 9 29.0002 9.1 29.3002L15.4 63.0002C15.9 66.2002 18.6 68.5002 21.7 68.5002H95.3C98.5 68.5002 101.1 66.3002 101.6 63.1002L110.5 17.0002C111.1 13.5002 108.8 10.4002 105.3 9.7002C105 9.8002 104.6 9.80019 104.3 9.80019Z"
                      />
                      <path
                        className="fill-muted-foreground"
                        d="M45.4992 44.7001C47.2992 44.7001 48.7992 43.2001 48.7992 41.4001C48.7992 39.6001 47.2992 38.1001 45.4992 38.1001C43.6992 38.1001 42.1992 39.6001 42.1992 41.4001C42.1992 43.2001 43.6992 44.7001 45.4992 44.7001Z"
                      />
                      <path
                        className="fill-muted-foreground"
                        d="M71.1994 44.6C72.9994 44.6 74.4994 43.1 74.4994 41.3C74.4994 39.5 72.9994 38 71.1994 38C69.3994 38 67.8994 39.5 67.8994 41.3C67.8994 43.2 69.3994 44.6 71.1994 44.6Z"
                      />
                      <path
                        className="fill-muted-foreground"
                        d="M61.4998 48.2002H55.2998V49.7002H61.4998V48.2002Z"
                      />
                      <path
                        className="fill-muted"
                        d="M95.57 73.314C104.609 73.314 111.937 66.4184 111.937 57.8823C111.937 49.3463 104.577 42.4507 95.57 42.4507C86.5311 42.4507 79.2031 49.3463 79.2031 57.8823C79.2031 66.4184 86.5311 73.314 95.57 73.314Z"
                      />
                      <path
                        className="fill-muted-foreground/70"
                        d="M117.208 81.467L107.073 71.3404L109.397 69.0171L119.523 79.1517L117.208 81.467Z"
                      />
                      <path
                        className="fill-muted-foreground/70"
                        d="M120.731 85.0303L112.959 77.2591C112.31 76.6102 112.31 75.5526 112.959 74.9037C113.608 74.2548 114.666 74.2548 115.315 74.9037L123.086 82.6749C123.735 83.3238 123.735 84.3813 123.086 85.0303C122.437 85.6792 121.379 85.6792 120.731 85.0303Z"
                      />
                      <path
                        className="fill-muted-foreground/70"
                        d="M120.179 85.5268L110.18 75.5284C109.948 75.296 109.948 74.9195 110.18 74.6791L112.736 72.1235C112.968 71.8911 113.345 71.8911 113.585 72.1235L123.583 82.1219C123.816 82.3542 123.816 82.7307 123.583 82.9711L121.028 85.5268C120.787 85.7591 120.411 85.7591 120.179 85.5268Z"
                      />
                      <path
                        className="fill-muted-foreground"
                        d="M95.6011 38.7095C84.9938 38.7095 76.3975 47.3059 76.3975 57.9131C76.3975 68.5124 84.9938 77.1168 95.5931 77.1168C106.2 77.1168 114.797 68.5204 114.797 57.9131C114.797 47.3059 106.2 38.7095 95.6011 38.7095ZM95.6011 73.5597C87.0848 73.5597 80.1869 66.5496 80.1869 57.9051C80.1869 49.2607 87.0848 42.2586 95.6011 42.2586C104.117 42.2586 111.015 49.2687 111.015 57.9131C111.015 66.5576 104.109 73.5597 95.6011 73.5597Z"
                      />
                      <path
                        className="fill-muted-foreground"
                        d="M93.9808 61.0024C93.9452 60.7186 93.9097 60.4347 93.9097 60.1154C93.9097 58.9446 94.4074 57.8802 95.6516 56.9577L96.7181 56.1771C97.3936 55.6804 97.678 55.1127 97.678 54.4386C97.678 53.4451 96.967 52.4872 95.4739 52.4872C93.9097 52.4872 93.2342 53.729 93.2342 54.9708C93.2342 55.2192 93.2698 55.4675 93.3053 55.6804L90.5324 55.574C90.4613 55.2546 90.4258 54.8998 90.4258 54.5805C90.4258 52.2033 92.2033 49.9326 95.4383 49.9326C98.8511 49.9326 100.629 52.0614 100.629 54.2967C100.629 56.0352 99.7399 57.2415 98.4601 58.164L97.5713 58.8026C96.7892 59.3348 96.3982 60.0444 96.3982 60.9315V61.0734H93.9808V61.0024ZM95.225 62.4216C96.1849 62.4216 96.967 63.2022 96.967 64.1601C96.967 65.1181 96.1849 65.8987 95.225 65.8987C94.2652 65.8987 93.4831 65.1181 93.4831 64.1601C93.4831 63.2022 94.2652 62.4216 95.225 62.4216Z"
                      />
                    </svg>
                  );
                  
                  export default function EmptyStateDemo() {
                    return (
                      <DataTable<Customer>
                        overflowClass="border rounded-card"
                        columns={columns}
                        hoverable={false}
                        data={[]}
                        noData
                        pagingData={{ total: 0, pageIndex: 1, pageSize: 10 }}
                        renderEmptyState={
                          <div
                            aria-labelledby="data-table-empty-state-title"
                            className="flex w-full items-center justify-center px-4 py-8 sm:px-8 sm:py-12"
                          >
                            <div className="w-full max-w-md text-center">
                              <EmptyState
                                size={280}
                                illustration={
                                  <div className="relative isolate">
                                    <div
                                      aria-hidden="true"
                                      className="pointer-events-none absolute -inset-2 z-0"
                                    >
                                      <span className="absolute inset-0 rounded-full bg-card/70 blur-xl" />
                                      <span className="absolute inset-2 rounded-full bg-card" />
                                    </div>
                                    <div className="relative z-1">
                                      <SearchResultsIllustration />
                                    </div>
                                  </div>
                                }
                                offset={-60}
                                variant="dots"
                              >
                                <div>
                                  <div className="mb-4">
                                    <h4
                                      id="data-table-empty-state-title"
                                      className="text-lg font-semibold text-foreground"
                                    >
                                      No customers found
                                    </h4>
                                    <p className="mx-auto max-w-sm text-muted-foreground">
                                      Try adjusting your search or filters.
                                    </p>
                                  </div>
                                  <Button type="button">Clear filters</Button>
                                </div>
                              </EmptyState>
                            </div>
                          </div>
                        }
                      />
                    );
                  }

                  API

                  DataTable wraps TanStack Table - columns, Row<T>, and the render prop's table instance all come directly from that library; see its own docs for the full column-def and table-instance API. Sorting and pagination stay manual: DataTable expects data to already be sorted and sliced to the current page, then calls back through onSort / onPaginationChange / onPageSizeChange so a parent can re-sort or re-fetch. Column resizing and sticky column pinning are built into the default renderer when columnResize or columnPinning is enabled. Pass children only when you need to replace the table structure entirely, as the Drag And Drop example does.

                  PropDescriptionTypeDefault
                  columnsColumn definitions for the table.ColumnDef<T>[][]
                  dataRows to render for the current page.T[][]
                  loadingShows skeleton rows while empty, or dims the table when rows are already present.booleanfalse
                  noDataRenders the built-in empty state instead of rows.booleanfalse
                  renderEmptyStateReplaces the default empty-state content.ReactNode-
                  selectableAdds a checkbox column for row and select-all selection.booleanfalse
                  onRowSelectCalled when a single row's checkbox changes.(checked: boolean, row: T) => void-
                  onAllRowSelectCalled when the header select-all checkbox changes.(checked: boolean, rows: Row<T>[]) => void-
                  checkboxCheckedOverrides a row's checked state instead of TanStack's internal selection.(row: T) => boolean-
                  indeterminateCheckboxCheckedOverrides the header checkbox's checked state.(rows: Row<T>[]) => boolean-
                  onSortCalled when the sorted column or direction changes.(sort: OnSortParam) => void-
                  columnPinningPins columns at the logical start or end by column id and renders them sticky in the default table.ColumnPinningState-
                  columnResizeAdds resize handles to header cells. Pass { mode: 'onChange' } for live resizing while dragging.boolean | ColumnResizeConfigfalse
                  pagingDataCurrent page, page size, and total row count.{ total: number; pageIndex: number; pageSize: number }{ total: 0, pageIndex: 1, pageSize: 10 }
                  onPaginationChangeCalled when the page changes.(page: number) => void-
                  pageSizesOptions shown in the page-size select.number[][10, 25, 50, 100]
                  onPageSizeChangeCalled when the page-size select changes.(size: number) => void-
                  skeletonAvatarColumnsColumn indexes that render an avatar-shaped skeleton while loading.number[]-
                  skeletonAvatarPropsProps for those skeleton avatars.SkeletonProps-
                  childrenRenders the table body yourself from the underlying TanStack instance, instead of DataTable's default rows.(props: { table: Table<T> }) => ReactNode-
                  refExposes resetSorting() and resetSelected().Ref<DataTableResetHandle>-
                  ...tablePropsForwarded to the underlying Table, for example bordered, compact, or overflow.TableProps-

                  Types

                  type OnSortParam = { sortOrder: 'asc' | 'desc' | ''; sortKey: string | number };
                  type ColumnPinningState = { start: string[]; end: string[] };
                  type ColumnResizeConfig = { mode?: ColumnResizeMode };
                  type ColumnResizeMode = 'onChange' | 'onEnd';
                  type DataTableResetHandle = { resetSorting: () => void; resetSelected: () => void };