Docs
Tables

Tables

Display structured data in a scannable, organized way.

Tables are a fundamental pattern for displaying structured data in rows and columns. They provide a scannable, organized way to present collections of related information, making it easy for users to compare values, identify patterns, and take action on specific items.

The choice of table pattern depends on several factors: the complexity of the data, the level of interactivity required, the amount of data being displayed, and the context within the page layout.

Components

There are three main table patterns, each suited to different use cases:

  • Table is a low-level, presentational table component.
  • Data Table builds on Table and TanStack Table to provide a feature-rich data browsing experience (sorting, filtering, pagination, etc.).
  • Data Grid is a separate grid implementation used for highly interactive, spreadsheet-like surfaces and very large datasets.

Use Table when:

  • You need simple, static display
  • No filtering or complex behavior is needed

Use Data Table when:

  • You need sorting, pagination, filtering, search, or row actions
  • You want TanStack-powered behavior with table semantics

Use Data Grid when:

  • You need virtualization today
  • You need column resizing
  • You need spreadsheet-like editing

Data Table and Data Grid are both pattern components: they are composed from primitives and built per use case. They are not available as standalone components.

Table

Table is designed for simple, static tabular data presentation. It is a presentational wrapper around the HTML <table> element. Use it when:

  • Displaying a fixed, known number of rows
  • The data is primarily read-only
  • Sort, filter, or search actions are not required or can be basic
Loading...
import {
  Card,
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from 'ui'
 
const invoices = [
  {
    invoice: 'INV001',
    paymentStatus: 'Paid',
    totalAmount: '$250.00',
    paymentMethod: 'Credit card',
    description: 'Website design services',
  },
  {
    invoice: 'INV002',
    paymentStatus: 'Pending',
    totalAmount: '$150.00',
    paymentMethod: 'PayPal',
    description: 'Monthly subscription fee',
  },
  {
    invoice: 'INV003',
    paymentStatus: 'Unpaid',
    totalAmount: '$350.00',
    paymentMethod: 'Bank transfer',
    description: 'Consulting hours',
  },
  {
    invoice: 'INV004',
    paymentStatus: 'Paid',
    totalAmount: '$450.00',
    paymentMethod: 'Credit card',
    description: 'Software license renewal',
  },
  {
    invoice: 'INV005',
    paymentStatus: 'Paid',
    totalAmount: '$550.00',
    paymentMethod: 'PayPal',
    description: 'Custom development work',
  },
  {
    invoice: 'INV006',
    paymentStatus: 'Pending',
    totalAmount: '$200.00',
    paymentMethod: 'Bank transfer',
    description: 'Hosting and maintenance',
  },
  {
    invoice: 'INV007',
    paymentStatus: 'Unpaid',
    totalAmount: '$300.00',
    paymentMethod: 'Credit card',
    description: 'Training session package',
  },
]
 
export function TableDemo() {
  return (
    <Card className="w-full">
      <Table>
        <TableCaption className="border-0">A list of your recent invoices</TableCaption>
        <TableHeader>
          <TableRow>
            <TableHead>Invoice</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Method</TableHead>
            <TableHead className="hidden md:table-cell">Description</TableHead>
            <TableHead className="text-right">Amount</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {invoices.map((invoice) => (
            <TableRow key={invoice.invoice}>
              <TableCell className="text-foreground font-mono">{invoice.invoice}</TableCell>
              <TableCell className="text-foreground-lighter">{invoice.paymentStatus}</TableCell>
              <TableCell className="text-foreground-lighter">{invoice.paymentMethod}</TableCell>
              <TableCell className="hidden md:table-cell text-foreground-muted">
                {invoice.description}
              </TableCell>
              <TableCell className="text-right">{invoice.totalAmount}</TableCell>
            </TableRow>
          ))}
        </TableBody>
        <TableFooter>
          <TableRow>
            <TableCell colSpan={4}>Total</TableCell>
            <TableCell className="text-right">$2,250.00</TableCell>
          </TableRow>
        </TableFooter>
      </Table>
    </Card>
  )
}

Data Table

Data Table is a pattern component and is not exposed as a Design System component. It is built on top of Table and TanStack Table.

Data Table extends Table with column definitions and row models (via TanStack) for complex sorting, filtering, and row actions. Use it when:

  • Displaying large datasets that require pagination
  • Users need to perform complex sort, filter, or search actions through the data
  • Row selection is required

Data Table does not yet support virtualization, resizable columns, or advanced editors. These capabilities are planned as part of consolidation with Data Grid.

Loading...
'use client'
 
import {
  ColumnDef,
  ColumnFiltersState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  SortingState,
  useReactTable,
  VisibilityState,
} from '@tanstack/react-table'
import { ChevronDown, MoreVertical } from 'lucide-react'
import * as React from 'react'
 
import {
  Button,
  Card,
  Checkbox_Shadcn_,
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  Input,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableHeadSort,
  TableRow,
} from 'ui'
 
const data: Payment[] = [
  {
    id: 'm5gr84i9',
    amount: 316,
    status: 'success',
    email: 'wallace@example.com',
  },
  {
    id: '3u1reuv4',
    amount: 242,
    status: 'success',
    email: 'wendolene@example.com',
  },
  {
    id: 'derv1ws0',
    amount: 837,
    status: 'processing',
    email: 'piella@example.com',
  },
  {
    id: '5kma53ae',
    amount: 874,
    status: 'success',
    email: 'victor@example.com',
  },
  {
    id: 'bhqecj4p',
    amount: 721,
    status: 'failed',
    email: 'feathers@example.com',
  },
]
 
export type Payment = {
  id: string
  amount: number
  status: 'pending' | 'processing' | 'success' | 'failed'
  email: string
}
 
export const columns: ColumnDef<Payment>[] = [
  {
    id: 'select',
    header: ({ table }) => (
      <Checkbox_Shadcn_
        checked={
          table.getIsAllPageRowsSelected() ||
          (table.getIsSomePageRowsSelected() ? 'indeterminate' : false)
        }
        onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
        aria-label="Select all"
      />
    ),
    cell: ({ row }) => (
      <Checkbox_Shadcn_
        checked={row.getIsSelected()}
        onCheckedChange={(value) => row.toggleSelected(!!value)}
        aria-label="Select row"
      />
    ),
    enableSorting: false,
    enableHiding: false,
  },
  {
    accessorKey: 'status',
    header: 'Status',
    enableSorting: true,
    cell: ({ row }) => <div className="capitalize">{row.getValue('status')}</div>,
  },
  {
    accessorKey: 'email',
    header: 'Email',
    enableSorting: true,
    cell: ({ row }) => <div className="lowercase">{row.getValue('email')}</div>,
  },
  {
    accessorKey: 'amount',
    header: () => <div className="text-right">Amount</div>,
    enableSorting: true,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue('amount'))
 
      // Format the amount as a dollar amount
      const formatted = new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency: 'USD',
      }).format(amount)
 
      return <div className="text-right">{formatted}</div>
    },
  },
  {
    id: 'actions',
    enableHiding: false,
    header: () => <span className="sr-only">Actions</span>,
    cell: ({ row }) => {
      const payment = row.original
 
      return (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button type="default" className="px-1.5" icon={<MoreVertical />} />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="max-w-48">
            <DropdownMenuItem onClick={() => navigator.clipboard.writeText(payment.id)}>
              Copy payment ID
            </DropdownMenuItem>
            <DropdownMenuSeparator />
            <DropdownMenuItem>View customer</DropdownMenuItem>
            <DropdownMenuItem>View payment details</DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      )
    },
  },
]
 
/**
 * Demo React component showcasing a client-side data table with sorting, filtering, column visibility, row selection, and pagination controls.
 *
 * Renders a fully interactive table UI bound to local state and TanStack Table: filter input for email, column visibility dropdown, sortable headers with three-state cycling, per-row selection checkboxes, row action menu, and previous/next pagination controls.
 *
 * @returns A React element that renders the interactive data table demo.
 */
export function DataTableDemo() {
  const [sorting, setSorting] = React.useState<SortingState>([])
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
  const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
  const [rowSelection, setRowSelection] = React.useState({})
 
  // Convert TanStack Table's SortingState to the string format expected by TableHeadSort
  const getSortString = React.useMemo(() => {
    if (sorting.length === 0) return ''
    const sort = sorting[0]
    return `${sort.id}:${sort.desc ? 'desc' : 'asc'}`
  }, [sorting])
 
  // Handle sort changes from TableHeadSort and convert to TanStack Table's SortingState
  const handleSortChange = React.useCallback(
    (column: string) => {
      const currentSort = sorting.find((s) => s.id === column)
      if (currentSort) {
        if (currentSort.desc) {
          // Cycle: desc -> remove sort
          setSorting([])
        } else {
          // Cycle: asc -> desc
          setSorting([{ id: column, desc: true }])
        }
      } else {
        // New column, start with asc
        setSorting([{ id: column, desc: false }])
      }
    },
    [sorting]
  )
 
  const table = useReactTable({
    data,
    columns,
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onColumnVisibilityChange: setColumnVisibility,
    onRowSelectionChange: setRowSelection,
    state: {
      sorting,
      columnFilters,
      columnVisibility,
      rowSelection,
    },
  })
 
  return (
    <div className="w-full flex flex-col gap-4">
      {/* Filters and column visibility controls */}
      <div className="flex items-center">
        <Input
          size="tiny"
          placeholder="Filter by email"
          value={(table.getColumn('email')?.getFilterValue() as string) ?? ''}
          onChange={(event) => table.getColumn('email')?.setFilterValue(event.target.value)}
          className="max-w-sm"
        />
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button type="default" className="ml-auto" size="tiny" iconRight={<ChevronDown />}>
              Columns
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end" className="max-w-48">
            {table
              .getAllColumns()
              .filter((column) => column.getCanHide())
              .map((column) => {
                return (
                  <DropdownMenuCheckboxItem
                    key={column.id}
                    className="capitalize"
                    checked={column.getIsVisible()}
                    onCheckedChange={(value) => column.toggleVisibility(!!value)}
                  >
                    {column.id}
                  </DropdownMenuCheckboxItem>
                )
              })}
          </DropdownMenuContent>
        </DropdownMenu>
      </div>
      {/* Table */}
      <Card className="w-full">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const columnId = header.column.id
                  const canSort = header.column.getCanSort()
 
                  return (
                    <TableHead
                      key={header.id}
                      className={
                        columnId === 'amount'
                          ? 'text-right'
                          : columnId === 'actions'
                            ? 'w-1'
                            : undefined
                      }
                    >
                      {header.isPlaceholder ? null : canSort ? (
                        <TableHeadSort
                          column={columnId}
                          currentSort={getSortString}
                          onSortChange={handleSortChange}
                          className={columnId === 'amount' ? 'justify-end' : undefined}
                        >
                          {flexRender(header.column.columnDef.header, header.getContext())}
                        </TableHeadSort>
                      ) : (
                        flexRender(header.column.columnDef.header, header.getContext())
                      )}
                    </TableHead>
                  )
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
                  {row.getVisibleCells().map((cell) => (
                    <TableCell
                      key={cell.id}
                      className={
                        cell.column.id === 'email'
                          ? 'text-foreground-lighter'
                          : cell.column.id === 'actions'
                            ? 'w-1'
                            : undefined
                      }
                    >
                      {flexRender(cell.column.columnDef.cell, cell.getContext())}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell colSpan={columns.length}>
                  <p className="text-sm text-foreground">No results found</p>
                  <p className="text-sm text-foreground-lighter">
                    Your search did not return any results
                  </p>
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </Card>
      {/* Count and pagination controls */}
      <div className="flex items-center justify-end space-x-2">
        <div className="text-foreground-muted flex-1 text-xs">
          {table.getFilteredSelectedRowModel().rows.length} of{' '}
          {table.getFilteredRowModel().rows.length} row(s) selected
        </div>
        <div className="space-x-2">
          <Button
            type="default"
            size="tiny"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            Previous
          </Button>
          <Button
            type="default"
            size="tiny"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            Next
          </Button>
        </div>
      </div>
    </div>
  )
}

As you can see from the above example, Data Table’s composition is heavily dependent on use case. We do not yet have a shared Design System component for this reason.

Follow Shadcn’s Data Table documentation for a complete guide on building upon the Data Table pattern for each specific use case. These patterns map closely to the approaches we follow internally.

Data Grid

Data Grid is a pattern component and is not exposed as a Design System component. It is based on React Data Grid and originally adopted for areas including Studio’s Table Editor, Query Performance, and other high-interaction surfaces.

Use it only when you need virtualization, column resizing, or complex cell editing. Otherwise Data Table is simpler and more flexible.

Loading...
import { useState } from 'react'
import DataGrid, { Column, useRowSelection } from 'react-data-grid'
import 'react-data-grid/lib/styles.css'
import { Checkbox_Shadcn_, cn } from 'ui'
 
type User = {
  id: string
  name: string
  email: string
  phone: string
}
 
/**
 * Render a data grid demo with selectable rows and sample user data.
 *
 * Renders a DataGrid configured with a checkbox column for per-row selection, columns for display name,
 * email, and phone, and ten hard-coded sample users. Selection state is managed internally and applied
 * to row styling.
 *
 * @returns A React element that renders the configured DataGrid with row selection and sample rows.
 */
export function DataGridDemo() {
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
 
  const columns: Column<User>[] = [
    {
      key: 'checkbox',
      name: '',
      width: 50,
      resizable: false,
      headerCellClass: 'border-default border-r border-b',
      renderCell: ({ row }) => {
        // eslint-disable-next-line react-hooks/rules-of-hooks
        const [isRowSelected, onRowSelectionChange] = useRowSelection()
 
        return (
          <div className="flex items-center justify-center h-full">
            <Checkbox_Shadcn_
              checked={isRowSelected}
              onClick={(e) => {
                e.stopPropagation()
                onRowSelectionChange({
                  row,
                  type: 'ROW',
                  checked: !isRowSelected,
                  isShiftClick: e.shiftKey,
                })
              }}
            />
          </div>
        )
      },
    },
    {
      key: 'name',
      name: 'Display name',
      minWidth: 200,
      resizable: true,
      headerCellClass: 'border-default border-r border-b',
    },
    {
      key: 'email',
      name: 'Email',
      minWidth: 250,
      resizable: true,
      headerCellClass: 'border-default border-r border-b',
    },
    {
      key: 'phone',
      name: 'Phone',
      minWidth: 150,
      resizable: true,
      headerCellClass: 'border-default border-b',
    },
  ]
 
  const rows: User[] = [
    {
      id: '1',
      name: 'Wallace',
      email: 'wallace@example.com',
      phone: '+44 1234 567890',
    },
    {
      id: '2',
      name: 'Gromit',
      email: 'gromit@example.com',
      phone: '+44 1234 567891',
    },
    {
      id: '3',
      name: 'Wendolene Ramsbottom',
      email: 'wendolene@example.com',
      phone: '+44 1234 567892',
    },
    {
      id: '4',
      name: 'Feathers McGraw',
      email: 'feathers@example.com',
      phone: '+44 1234 567893',
    },
    {
      id: '5',
      name: 'Preston',
      email: 'preston@example.com',
      phone: '+44 1234 567894',
    },
    {
      id: '6',
      name: 'Piella Bakewell',
      email: 'piella@example.com',
      phone: '+44 1234 567895',
    },
    {
      id: '7',
      name: 'Victor Quartermaine',
      email: 'victor@example.com',
      phone: '+44 1234 567896',
    },
    {
      id: '8',
      name: 'Lady Tottington',
      email: 'lady@example.com',
      phone: '+44 1234 567897',
    },
    {
      id: '9',
      name: 'Shaun',
      email: 'shaun@example.com',
      phone: '+44 1234 567898',
    },
    {
      id: '10',
      name: 'Hutch',
      email: 'hutch@example.com',
      phone: '+44 1234 567899',
    },
  ]
 
  return (
    <div className="h-full w-full flex flex-col relative min-h-[400px] rounded-md border overflow-hidden">
      <DataGrid
        className="flex-grow border-t-0 bg-dash-canvas"
        rowHeight={44}
        headerRowHeight={36}
        columns={columns}
        rows={rows}
        rowKeyGetter={(row: User) => row.id}
        rowClass={(row, idx) => {
          const isSelected = selectedRows.has(row.id)
          const isLastRow = idx === rows.length - 1
          return cn(
            'bg-surface-75',
            isSelected && 'bg-surface-200',
            '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
            '[&>.rdg-cell]:border-secondary [&>.rdg-cell:not(:last-child)]:border-r',
            !isLastRow && '[&>.rdg-cell]:border-b',
            '[&>.rdg-cell:nth-child(2)>div]:ml-8'
          )
        }}
        selectedRows={selectedRows}
        onSelectedRowsChange={setSelectedRows}
      />
    </div>
  )
}

Future

Data Table and Data Grid overlap significantly. We’re looking at consolidating these into one data table component, which will likely be improvements to Data Table given the numerous advantages of TanStack Table and difficulties extending React Data Grid.

The likely direction is a single Data Table component built on TanStack Table, with

  • Virtualization
  • Resizable columns
  • Plug-in cell editors
  • Shared filtering/sorting utilities
  • Shared UI patterns (horizontal and vertical filter bars, date pickers, side panels)
  • Accessible, semantic HTML table markup