Components

DataTable

A powerful data table component with sorting, filtering, pagination, row selection, and column customization built on TanStack Table.

Installation

npm install @marmoui/ui
yarn add @marmoui/ui
pnpm add @marmoui/ui
bun add @marmoui/ui

Usage

import { DataTable } from '@marmoui/ui';
import type { ColumnDef } from '@tanstack/react-table';

DataTable requires two props: data (array of row objects) and columns (TanStack Table column definitions). Everything else is optional.

Examples

Basic Table

Simple DataTable

Minimal table with columns and data

Showing 5 of 5 Items

Name
Email
Role
John Doejohn@example.comAdmin
Jane Smithjane@example.comEditor
Bob Wilsonbob@example.comViewer
Alice Brownalice@example.comEditor
Charlie Davischarlie@example.comAdmin
Item per page
import type { ColumnDef } from '@tanstack/react-table';

interface User {
id: string;
name: string;
email: string;
role: string;
}

const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
];

const data: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com', role: 'Admin' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', role: 'Editor' },
];

<DataTable data={data} columns={columns} />

Custom Cell Rendering with Avatars and Badges

In production, table cells often contain rich content. Use the cell property in column definitions to render avatars, badges, links, and inline actions.

Rich Cell Content

Columns with avatar, badge status, and link rendering

Showing 5 of 5 Items

User
Role
Status
JD
John Doe
john@example.com
Admin
active
JS
Jane Smith
jane@example.com
Editor
active
BW
Bob Wilson
bob@example.com
Viewer
inactive
AB
Alice Brown
alice@example.com
Editor
active
CD
Charlie Davis
charlie@example.com
Admin
active
Item per page
const columns: ColumnDef<Contact>[] = [
{
  accessorKey: 'name',
  header: 'User',
  cell: ({ row }) => (
    <div className="flex items-center gap-3">
      <Avatar size="sm">
        <AvatarFallback>
          {row.original.name.split(' ').map(n => n[0]).join('')}
        </AvatarFallback>
      </Avatar>
      <div>
        <div className="font-medium">{row.original.name}</div>
        <div className="text-sm text-ink-light">{row.original.email}</div>
      </div>
    </div>
  ),
  size: 400,
  enableHiding: false,
},
{
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => (
    <Badge variant={row.original.status === 'active' ? 'success' : 'default'}>
      {row.original.status}
    </Badge>
  ),
},
{
  accessorKey: 'list_name',
  header: 'List',
  cell: ({ row }) => (
    <span className="text-ink-light">{row.original.list_name || '—'}</span>
  ),
},
];

Row Actions with TableCellActions

Use TableCellActions to add hover-revealed action buttons to each row. Pass the cell's visible content via the content prop — actions overlay it on row hover. No extra column needed.

Row Actions

Hover-revealed action buttons overlaying the last cell

Showing 5 of 5 Items

Contact
Role
Status
JD
john@example.com
Admin
active
JS
jane@example.com
Editor
active
BW
bob@example.com
Viewer
inactive
AB
alice@example.com
Editor
active
CD
charlie@example.com
Admin
active
Item per page
import { TableCellActions, ButtonGroup, Button, IconButton, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, Badge } from '@marmoui/ui';
import { MdMoreHoriz, MdOutlineEdit, MdOutlineEmail, MdOutlineDelete, MdOutlinePersonAdd, MdPersonOutline } from 'react-icons/md';

const columns = [
{ accessorKey: 'email', header: 'Contact', size: 300 },
{ accessorKey: 'role', header: 'Role' },
{
  accessorKey: 'status',
  header: 'Status',
  cell: (info) => (
    <TableCellActions
      row={info.row}
      table={info.table}
      content={
        <Badge variant={info.row.original.status === 'active' ? 'success' : 'default'}>
          {info.row.original.status}
        </Badge>
      }
    >
      <ButtonGroup attached={true} size="sm">
        <Button variant="secondary">View</Button>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <IconButton variant="secondary" size="sm" aria-label="More options" icon={<MdMoreHoriz />} />
          </DropdownMenuTrigger>
          <DropdownMenuContent className="min-w-52">
            <DropdownMenuItem><MdPersonOutline /> View Profile</DropdownMenuItem>
            <DropdownMenuItem><MdOutlineEdit /> Edit Contact</DropdownMenuItem>
            <DropdownMenuItem><MdOutlineEmail /> Send Email</DropdownMenuItem>
            <DropdownMenuItem><MdOutlinePersonAdd /> Add to List</DropdownMenuItem>
            <DropdownMenuItem variant="destructive"><MdOutlineDelete /> Delete</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </ButtonGroup>
    </TableCellActions>
  ),
},
];

Row Actions with Sticky Column

When using columnPinning, the primary column stays fixed while other columns scroll. This is useful for wide tables with many columns.

Row Actions with Sticky Column

Pinned contact column stays visible while other columns scroll

Showing 5 of 5 Items

Contact
Name
Role
Status
JD
john@example.com
John DoeAdmin
active
JS
jane@example.com
Jane SmithEditor
active
BW
bob@example.com
Bob WilsonViewer
inactive
AB
alice@example.com
Alice BrownEditor
active
CD
charlie@example.com
Charlie DavisAdmin
active
Item per page
<DataTable
data={contacts}
columns={columns}
columnPinning={{ left: ['email'] }}
/>

Filters

DataTable supports multiple filter types: select, multiselect, date, and boolean. Filters appear in the table toolbar and update tableState.filters.

Multi-type Filters

Combine multiselect, date range, and other filter types

Showing 5 of 5 Items

User
Role
Status
JD
John Doe
john@example.com
Admin
active
JS
Jane Smith
jane@example.com
Editor
active
BW
Bob Wilson
bob@example.com
Viewer
inactive
AB
Alice Brown
alice@example.com
Editor
active
CD
Charlie Davis
charlie@example.com
Admin
active
Item per page
<DataTable
data={contacts}
columns={columns}
filters={[
  {
    id: 'lists',
    label: 'List(s)',
    type: 'multiselect',
    placeholder: 'Select lists',
    searchPlaceholder: 'Search lists...',
    searchable: true,
    options: lists.map(list => ({ label: list.name, value: list.id })),
  },
  {
    id: 'audiences',
    label: 'Audiences',
    type: 'multiselect',
    placeholder: 'Select audiences',
    searchPlaceholder: 'Search audiences...',
    searchable: true,
    options: audiences.map(a => ({ label: a.name, value: a.id })),
  },
  {
    id: 'dateRange',
    label: 'Sign Up Date',
    type: 'date',
    placeholder: 'Select date range',
    mode: 'range',
  },
  {
    id: 'status',
    label: 'Status',
    type: 'select',
    options: [
      { value: 'active', label: 'Active' },
      { value: 'inactive', label: 'Inactive' },
    ],
  },
]}
/>

Row Selection with Bulk Actions

Enable row selection to show a floating action bar. The actionBarActions prop receives the selected rows and renders bulk action buttons.

Bulk Actions

Select rows and perform bulk operations like send email, add to list, or delete

Showing 5 of 5 users

User
Role
Status
JD
John Doe
john@example.com
Admin
active
JS
Jane Smith
jane@example.com
Editor
active
BW
Bob Wilson
bob@example.com
Viewer
inactive
AB
Alice Brown
alice@example.com
Editor
active
CD
Charlie Davis
charlie@example.com
Admin
active
user per page
<DataTable
data={contacts}
columns={columns}
dataType="contact"
actionBarActions={(selectedContacts) => (
  <ButtonGroup attached={false} size="sm">
    <Button
      variant="secondary"
      size="sm"
      leftIcon={<MdEmail />}
      onClick={() => openDialog('sendEmail', { contacts: selectedContacts })}
    >
      Send Email
    </Button>
    <Button
      variant="secondary"
      size="sm"
      leftIcon={<MdPersonAdd />}
      onClick={() => openDialog('addToList', { contacts: selectedContacts })}
    >
      Add to List
    </Button>
    <Button
      variant="destructive"
      size="sm"
      leftIcon={<MdDelete />}
      onClick={() => openDialog('deleteContact', {
        dialogTitle: `Delete ${selectedContacts.length} contact${selectedContacts.length === 1 ? '' : 's'}?`,
        dialogDescription: 'This action cannot be undone.',
        contacts: selectedContacts,
      })}
    >
      Delete
    </Button>
    <IconButton variant="secondary" size="sm" icon={<MdMoreHoriz />} />
  </ButtonGroup>
)}
/>

Column Visibility

Control which columns are visible with columnVisibility and onColumnVisibilityChange. Users can toggle columns via the built-in columns menu.

Column Visibility

Let users show/hide columns dynamically

Showing 5 of 5 Items

Name
Email
Role
Status
John Doejohn@example.comAdmin
active
Jane Smithjane@example.comEditor
active
Bob Wilsonbob@example.comViewer
inactive
Alice Brownalice@example.comEditor
active
Charlie Davischarlie@example.comAdmin
active
Item per page
function ContactsTable() {
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({
  phone: true,
  list_name: true,
  created_at: false, // hidden by default
});

return (
  <DataTable
    data={contacts}
    columns={columns}
    columnVisibility={columnVisibility}
    onColumnVisibilityChange={setColumnVisibility}
  />
);
}

Server-Side Integration with TableState

For server-side pagination, sorting, and filtering, use the controlled tableState and setTableState props. The table communicates all state changes through setTableState, and you send those to your API.

Controlled Table State with Data Fetching

Server-side pagination, search, and filters using fetch and useEffect

Showing 5 of 5 Items

Name
Email
Role
John Doejohn@example.comAdmin
Jane Smithjane@example.comEditor
Bob Wilsonbob@example.comViewer
Alice Brownalice@example.comEditor
Charlie Davischarlie@example.comAdmin
Item per page
import { useState, useEffect, useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@marmoui/ui';
import type { TableState } from '@marmoui/ui';

interface Contact {
id: string;
name: string;
email: string;
status: string;
}

function ContactsPage() {
const [data, setData] = useState<Contact[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [tableState, setTableState] = useState<TableState>({
  pagination: { pageIndex: 0, pageSize: 10 },
  sorting: [],
  filters: {},
  columns: [],
  search: '',
  view: 'list',
});

// Fetch data whenever tableState changes
useEffect(() => {
  const fetchContacts = async () => {
    setIsLoading(true);
    try {
      const params = new URLSearchParams({
        page: String(tableState.pagination.pageIndex),
        pageSize: String(tableState.pagination.pageSize),
        search: tableState.search || '',
        ...Object.fromEntries(
          Object.entries(tableState.filters).map(([k, v]) => [k, String(v)])
        ),
      });

      const res = await fetch(`/api/contacts?${params}`);
      const json = await res.json();
      setData(json.data);
      setTotal(json.total);
    } finally {
      setIsLoading(false);
    }
  };

  fetchContacts();
}, [tableState.pagination, tableState.search, tableState.filters]);

const handleTableStateChange = (newState: Partial<TableState>) => {
  setTableState(prev => ({ ...prev, ...newState }));
};

const columns: ColumnDef<Contact>[] = [
  { accessorKey: 'name', header: 'Name', enableHiding: false },
  { accessorKey: 'email', header: 'Email' },
  { accessorKey: 'status', header: 'Status' },
];

return (
  <DataTable
    data={data}
    columns={columns}
    total={total}
    dataType="contact"
    isLoadingBody={isLoading}
    tableState={tableState}
    setTableState={handleTableStateChange}
    pageSize={tableState.pagination.pageSize}
    currentPage={tableState.pagination.pageIndex}
  />
);
}

Row Selection with Checkbox

The TableMainHeaderCell component adds a select-all checkbox to the primary column header. Combined with actionBarActions, users can select rows and perform bulk operations.

Checkbox Selection

Row selection with select-all header checkbox and bulk action bar

Showing 5 of 5 users

User
Role
Status
JD
John Doe
john@example.com
Admin
active
JS
Jane Smith
jane@example.com
Editor
active
BW
Bob Wilson
bob@example.com
Viewer
inactive
AB
Alice Brown
alice@example.com
Editor
active
CD
Charlie Davis
charlie@example.com
Admin
active
user per page
import { useMemo, useState, useEffect } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import {
DataTable, Box, Button, ButtonGroup, IconButton,
DropdownMenu, DropdownMenuItem, DropdownMenuContent,
DropdownMenuTrigger, TableCellActions, TableMainHeaderCell,
Badge, Avatar, AvatarFallback,
} from '@marmoui/ui';
import type { TableState } from '@marmoui/ui';
import { MdMoreHoriz, MdDelete, MdEmail, MdPersonAdd,
MdOutlineEdit, MdOutlineEmail, MdOutlineDelete,
MdOutlinePersonAdd, MdPersonOutline } from 'react-icons/md';

interface Contact {
id: string;
email: string;
name: string;
status: string;
list_name: string;
}

function ContactsWithCheckbox() {
const [data, setData] = useState<Contact[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [columnVisibility, setColumnVisibility] = useState({});
const [tableState, setTableState] = useState<TableState>({
  pagination: { pageIndex: 0, pageSize: 10 },
  sorting: [],
  filters: {},
  columns: [],
  search: '',
  view: 'list',
});

// Fetch data whenever tableState changes
useEffect(() => {
  const fetchData = async () => {
    setIsLoading(true);
    const params = new URLSearchParams({
      page: String(tableState.pagination.pageIndex),
      limit: String(tableState.pagination.pageSize),
      search: tableState.search || '',
    });
    const res = await fetch(`/api/contacts?${params}`);
    const json = await res.json();
    setData(json.data);
    setTotal(json.total);
    setIsLoading(false);
  };
  fetchData();
}, [tableState.pagination, tableState.search, tableState.filters]);

const columns = useMemo<ColumnDef<Contact>[]>(() => [
  {
    id: 'email',
    accessorKey: 'email',
    // TableMainHeaderCell renders the select-all checkbox
    header: ({ table }) => (
      <TableMainHeaderCell label="Email" columnIndex={0} table={table} />
    ),
    cell: (info) => {
      const contact = info.row.original;
      return (
        <div className="flex items-center gap-1.5 w-full group/row">
          <Avatar size="sm">
            <AvatarFallback>
              {contact.name.split(' ').map(n => n[0]).join('')}
            </AvatarFallback>
          </Avatar>
          <div className="flex-1 min-w-0">
            <span className="text-ink-dark font-medium truncate block">
              {contact.email}
            </span>
          </div>
          <div className="flex-shrink-0 opacity-0 group-hover/row:opacity-100 transition-opacity">
            <TableCellActions row={info.row} table={info.table}>
              <ButtonGroup attached={true} size="sm">
                <Button variant="secondary">View</Button>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <IconButton variant="secondary" size="sm"
                      aria-label="More" icon={<MdMoreHoriz />} />
                  </DropdownMenuTrigger>
                  <DropdownMenuContent className="min-w-52">
                    <DropdownMenuItem><MdPersonOutline /> View Profile</DropdownMenuItem>
                    <DropdownMenuItem><MdOutlineEdit /> Edit</DropdownMenuItem>
                    <DropdownMenuItem><MdOutlineEmail /> Send Email</DropdownMenuItem>
                    <DropdownMenuItem variant="destructive">
                      <MdOutlineDelete /> Delete
                    </DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </ButtonGroup>
            </TableCellActions>
          </div>
        </div>
      );
    },
    size: 400,
    enableHiding: false,
  },
  {
    accessorKey: 'name',
    header: 'Name',
    meta: { label: 'Name', group: 'Contact Info' },
  },
  {
    accessorKey: 'status',
    header: 'Status',
    cell: ({ row }) => (
      <Badge variant={row.original.status === 'active' ? 'success' : 'default'}>
        {row.original.status}
      </Badge>
    ),
    meta: { label: 'Status', group: 'Contact Info' },
  },
  {
    accessorKey: 'list_name',
    header: 'List',
    meta: { label: 'List', group: 'Contact Info' },
  },
], []);

return (
  <DataTable<Contact>
    columns={columns}
    data={data}
    total={total}
    dataType="contact"
    isLoadingBody={isLoading}
    tableState={tableState}
    setTableState={(s) => setTableState(prev => ({ ...prev, ...s }))}
    onColumnVisibilityChange={setColumnVisibility}
    columnVisibility={columnVisibility}
    pageSize={tableState.pagination.pageSize}
    currentPage={tableState.pagination.pageIndex}
    filters={[
      {
        id: 'lists',
        label: 'List(s)',
        type: 'multiselect',
        placeholder: 'Select lists',
        searchable: true,
        options: [
          { label: 'Newsletter', value: '1' },
          { label: 'VIP Members', value: '2' },
        ],
      },
      {
        id: 'dateRange',
        label: 'Sign Up Date',
        type: 'date',
        mode: 'range',
      },
    ]}
    actionBarActions={(selectedContacts) => (
      <ButtonGroup attached={false} size="sm">
        <Button variant="secondary" size="sm" leftIcon={<MdEmail />}>
          Send Email
        </Button>
        <Button variant="secondary" size="sm" leftIcon={<MdPersonAdd />}>
          Add to List
        </Button>
        <Button variant="destructive" size="sm" leftIcon={<MdDelete />}>
          Delete
        </Button>
        <IconButton variant="secondary" size="sm" icon={<MdMoreHoriz />} />
      </ButtonGroup>
    )}
  />
);
}

Loading State

Loading State

Show skeleton rows while data is being fetched

Showing 0 of 0 contacts

Name
Email
Role
Loading...
contact per page
<DataTable
data={[]}
columns={columns}
isLoadingBody={true}
dataType="contact"
/>

Empty State

Empty State

Show contextual message when no data matches

Showing 0 of 0 contacts

Name
Email
Role

No contacts found

Get started by creating your first contact.

contact per page
<DataTable
data={[]}
columns={columns}
dataType="contact"
/>

Real-World Example

A full production-style contacts table combining row selection, column visibility, filters, bulk actions, and pinned columns.

Contacts Management Table

Full-featured table with search, filters, column visibility, row actions, and bulk actions

Showing 5 of 5 contacts

Name
Phone
Status
List
JD
john@example.com
John Doe(555) 123-4567
active
Newsletter
JS
jane@example.com
Jane Smith(555) 234-5678
active
VIP Members
BW
bob@example.com
Bob Wilson(555) 345-6789
inactive
Newsletter
AB
alice@example.com
Alice Brown(555) 456-7890
active
Customers
CD
charlie@example.com
Charlie Davis(555) 567-8901
active
VIP Members
contact per page
import { useState, useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import {
DataTable, Button, ButtonGroup, IconButton, Badge,
Avatar, AvatarFallback,
DropdownMenu, DropdownMenuItem, DropdownMenuContent,
DropdownMenuTrigger, TableCellActions, TableMainHeaderCell,
} from '@marmoui/ui';
import type { TableState } from '@marmoui/ui';
import {
MdMoreHoriz, MdDelete, MdEmail, MdPersonAdd,
MdOutlineEdit, MdOutlineEmail, MdOutlineDelete,
MdOutlinePersonAdd, MdPersonOutline,
} from 'react-icons/md';

function ContactsList() {
const [tableState, setTableState] = useState<TableState>({
  pagination: { pageIndex: 0, pageSize: 10 },
  sorting: [], filters: {}, columns: [], search: '', view: 'list',
});
const [columnVisibility, setColumnVisibility] = useState({
  phone: true, list_name: true, created_at: false,
});

const columns = useMemo<ColumnDef<Contact>[]>(() => [
  {
    id: 'email',
    accessorKey: 'email',
    header: ({ table }) => (
      <TableMainHeaderCell label="Email" columnIndex={0} table={table} />
    ),
    cell: (info) => {
      const contact = info.row.original;
      return (
        <div className="flex items-center gap-1.5 w-full group/row">
          <Avatar size="sm">
            <AvatarFallback>
              {contact.name.split(' ').map(n => n[0]).join('')}
            </AvatarFallback>
          </Avatar>
          <div className="flex-1 min-w-0">
            <span className="text-ink-dark font-medium truncate block">
              {contact.email}
            </span>
          </div>
          <TableCellActions row={info.row} table={info.table}>
            <ButtonGroup attached={true} size="sm">
              <Button variant="secondary">View</Button>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <IconButton variant="secondary" size="sm"
                    aria-label="More" icon={<MdMoreHoriz />} />
                </DropdownMenuTrigger>
                <DropdownMenuContent className="min-w-52">
                  <DropdownMenuItem><MdPersonOutline /> View Profile</DropdownMenuItem>
                  <DropdownMenuItem><MdOutlineEdit /> Edit</DropdownMenuItem>
                  <DropdownMenuItem><MdOutlineEmail /> Send Email</DropdownMenuItem>
                  <DropdownMenuItem><MdOutlinePersonAdd /> Add to List</DropdownMenuItem>
                  <DropdownMenuItem variant="destructive">
                    <MdOutlineDelete /> Delete
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </ButtonGroup>
          </TableCellActions>
        </div>
      );
    },
    size: 400,
    enableHiding: false,
  },
  { accessorKey: 'name', header: 'Name', meta: { label: 'Name', group: 'Contact Info' } },
  { accessorKey: 'phone', header: 'Phone', meta: { label: 'Phone', group: 'Contact Info' } },
  {
    accessorKey: 'status', header: 'Status',
    cell: ({ row }) => (
      <Badge variant={row.original.status === 'active' ? 'success' : 'default'}>
        {row.original.status}
      </Badge>
    ),
    meta: { label: 'Status', group: 'Contact Info' },
  },
  { accessorKey: 'list_name', header: 'List', meta: { label: 'List', group: 'Contact Info' } },
  {
    accessorKey: 'created_at', header: 'Created',
    cell: ({ row }) => new Date(row.original.created_at).toLocaleDateString(),
    meta: { label: 'Created', group: 'Details' },
  },
], []);

return (
  <DataTable<Contact>
    columns={columns}
    data={contacts}
    total={contacts.length}
    dataType="contact"
    tableState={tableState}
    setTableState={(s) => setTableState(prev => ({ ...prev, ...s }))}
    onColumnVisibilityChange={setColumnVisibility}
    columnVisibility={columnVisibility}
    columnPinning={{ left: ['email'] }}
    filters={[
      {
        id: 'lists', label: 'List(s)', type: 'multiselect',
        placeholder: 'Select lists', searchable: true,
        options: [
          { label: 'Newsletter', value: 'newsletter' },
          { label: 'VIP Members', value: 'vip' },
        ],
      },
      { id: 'dateRange', label: 'Sign Up Date', type: 'date', mode: 'range' },
    ]}
    actionBarActions={(selected) => (
      <ButtonGroup attached={false} size="sm">
        <Button variant="secondary" leftIcon={<MdEmail />}>Send Email</Button>
        <Button variant="secondary" leftIcon={<MdPersonAdd />}>Add to List</Button>
        <Button variant="destructive" leftIcon={<MdDelete />}>Delete</Button>
        <IconButton variant="secondary" size="sm" icon={<MdMoreHoriz />} />
      </ButtonGroup>
    )}
  />
);
}

Column Definition Reference

Columns follow the TanStack Table ColumnDef format:

import type { ColumnDef } from '@tanstack/react-table';

const columns: ColumnDef<YourDataType>[] = [
  {
    id: 'uniqueId',           // Unique column ID (optional if accessorKey is set)
    accessorKey: 'fieldName', // Property name on the data object
    header: 'Display Name',   // String or render function for header
    cell: ({ row }) => ...,   // Custom cell renderer (optional)
    enableSorting: true,      // Allow sorting (default: true)
    enableHiding: true,       // Allow toggling visibility (default: true)
    size: 200,                // Column width in pixels
    meta: {
      label: 'Field Label',  // Label shown in column visibility menu
      group: 'Group Name',   // Group in column visibility menu
    },
  },
];

Best Practices

Use custom cell renderers for readability

Enhance data presentation with avatars, badges, formatted dates, and links. Raw data is harder to scan.

Provide contextual empty states

Use the dataType prop (e.g., "contact") so empty states say "No contacts found" instead of generic text.

Show loading states during data fetches

Set isLoadingBody={true} while fetching. Skeleton rows reassure users that data is on the way.

Pin the primary column

Set enableHiding: false on the primary identifier column (e.g., email) so users always have context.

Don't show too many columns by default

Start with 4-6 visible columns. Let users toggle additional columns via the column menu.

Don't skip pagination for large datasets

Always paginate when dealing with more than 50 rows. Use server-side pagination for datasets over 1,000 rows.

Props

DataTable

PropTypeDefaultDescription
dataT[]Array of row data objects
columnsColumnDef<T>[]TanStack Table column definitions
dataTypestring'Item'Data label for empty states and pagination text
totalnumberTotal records (for server-side pagination)
pageSizenumber10Rows per page
currentPagenumber0Current page index (0-based)
isLoadingBodybooleanfalseShow skeleton loading in table body
isLoadingHeadersbooleanfalseShow skeleton loading for entire table
tableStateTableStateControlled table state object
setTableState(state: Partial<TableState>) => voidCallback when table state changes
searchPlaceholderstring'Search...'Placeholder for the search input
hideSearchbooleanfalseHide the search input
hideColumnsMenubooleanfalseHide column visibility toggle
filtersTableFilter[]Filter configurations (see below)
columnVisibilityRecord<string, boolean>{}Controlled column visibility state
onColumnVisibilityChange(visibility) => voidCallback when column visibility changes
onRowSelectionChange(selectedRows: T[]) => voidCallback when row selection changes
actionBarActionsReactNode | ((rows: T[]) => ReactNode)Bulk action buttons for selected rows
columnPinning{ left?: string[]; right?: string[] }Pin columns to left or right edges (sticky)
maxHeightstringfull-height viewport calc with CSS variable fallbacksHeight of the internal table scroll area
classNamestringAdditional className

TableState

interface TableState {
  pagination: { pageIndex: number; pageSize: number };
  sorting: SortingState;
  filters: Record<string, any>;
  columns: ColumnOrderState;
  search: string;
  view: 'list' | 'grid';
}

Filter Types

// Single select filter
{ id: 'status', label: 'Status', type: 'select', options: [...] }

// Multi-select filter with search
{ id: 'lists', label: 'Lists', type: 'multiselect', searchable: true, options: [...] }

// Date range filter
{ id: 'dateRange', label: 'Date', type: 'date', mode: 'range' }

// Boolean filter
{ id: 'verified', label: 'Verified', type: 'boolean', trueLabel: 'Yes', falseLabel: 'No' }

Patterns

Minimal local table (no server pagination)

Pass data and columns — sorting and client-side pagination work automatically.

import { DataTable } from '@marmoui/ui';
import type { ColumnDef } from '@tanstack/react-table';

interface User { id: string; name: string; email: string; }

const columns: ColumnDef<User>[] = [
  { accessorKey: 'name', header: 'Name' },
  { accessorKey: 'email', header: 'Email' },
];

<DataTable columns={columns} data={users} pageSize={20} />

Server-side pagination with tableState

const [tableState, setTableState] = useState<DataTableParams>({
  page: 1, pageSize: 20, search: '', sortBy: '', sortDir: 'asc',
});

<DataTable
  columns={columns}
  data={data}
  total={totalCount}
  tableState={tableState}
  setTableState={setTableState}
  isLoadingBody={isLoading}
/>

Status badge in a cell

{
  accessorKey: 'status',
  header: 'Status',
  cell: ({ row }) => {
    const variantMap = {
      active:   'success',
      pending:  'warning',
      inactive: 'normal',
      failed:   'destructive',
    } as const;
    return (
      <Badge variant={variantMap[row.original.status]}>
        {row.original.status}
      </Badge>
    );
  },
}

Common Mistakes

  • pageSize is a real prop<DataTable columns={columns} data={data} pageSize={20} /> is correct. Default is 10. Do not omit it when you need a specific page size.
  • pageSize is static — it is the initial/controlled rows-per-page value. For server-side pagination, also pass total, currentPage, tableState, and setTableState so the table knows the full dataset size and can compute page numbers.
  • Using enableSorting or enablePagination props — these do not exist; sorting is always on, pagination is controlled via tableState/setTableState.
  • Forgetting total for server-side pagination — without it the table treats the current data array as the full dataset.
  • Passing rendered JSX as column header string — use a string or a function: header: () => <span>Name</span>
  • Not importing ColumnDef from @tanstack/react-table — it must be imported separately alongside the DataTable import.
  • Using rows or items instead of data for the data prop — the prop name is always data.
  • dataType is singular — pass dataType="contact", not "contacts". The table pluralizes it for the count text and empty state ("Showing 4 of 20 contacts", "No contacts found", "creating your first contact").
  • Do not build your own toolbar above DataTable. Search, columns menu, filters, and the count text are built in. The page's primary action goes in PageSection's primaryAction, not in a row above the table.

Accessibility

Keyboard Navigation

DataTable supports full keyboard navigation. Use Tab to move between interactive elements, Enter or Space to activate buttons, and arrow keys within dropdowns.

  • Uses proper ARIA roles (table, rowgroup, row, columnheader, cell)
  • Sortable column headers are keyboard accessible with aria-sort
  • Row selection checkboxes are fully accessible with aria-checked
  • Action bar announces selected count to screen readers
  • Focus management for inline action buttons and dropdown menus
  • Loading and empty states are announced via aria-live regions