Skip to main content

Table Components

Proton provides table components for displaying structured data with support for sorting, responsive layouts, and custom styling.

Table

The main table component with responsive capabilities. Location: components/table/Table.tsx

Basic Usage

import Table from '@proton/components/components/table/Table';
import TableHeader from '@proton/components/components/table/TableHeader';
import TableBody from '@proton/components/components/table/TableBody';
import TableRow from '@proton/components/components/table/TableRow';
import TableCell from '@proton/components/components/table/TableCell';
import TableHeaderCell from '@proton/components/components/table/TableHeaderCell';

const MyTable = () => {
  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHeaderCell>Name</TableHeaderCell>
          <TableHeaderCell>Email</TableHeaderCell>
          <TableHeaderCell>Role</TableHeaderCell>
        </TableRow>
      </TableHeader>
      <TableBody>
        <TableRow>
          <TableCell>John Doe</TableCell>
          <TableCell>[email protected]</TableCell>
          <TableCell>Admin</TableCell>
        </TableRow>
        <TableRow>
          <TableCell>Jane Smith</TableCell>
          <TableCell>[email protected]</TableCell>
          <TableCell>User</TableCell>
        </TableRow>
      </TableBody>
    </Table>
  );
};

Props

children
ReactNode
required
Table content (typically TableHeader and TableBody)
className
string
Additional CSS classes
caption
string
Screen-reader accessible table caption
responsive
'cards' | 'stacked'
Responsive table layout mode
hasActions
boolean
Indicates table has action column. Default: false
borderWeak
boolean
Use weaker border styling. Default: false

Examples

<Table caption="User List">
  <TableHeader>
    <TableRow>
      <TableHeaderCell>Name</TableHeaderCell>
      <TableHeaderCell>Email</TableHeaderCell>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>John Doe</TableCell>
      <TableCell>[email protected]</TableCell>
    </TableRow>
  </TableBody>
</Table>

TableHeader

Table header container. Location: components/table/TableHeader.tsx

Usage

import TableHeader from '@proton/components/components/table/TableHeader';

<TableHeader>
  <TableRow>
    {/* Header cells */}
  </TableRow>
</TableHeader>

TableBody

Table body container. Location: components/table/TableBody.tsx

Usage

import TableBody from '@proton/components/components/table/TableBody';

<TableBody>
  {data.map(item => (
    <TableRow key={item.id}>
      {/* Table cells */}
    </TableRow>
  ))}
</TableBody>

TableRow

Table row component. Location: components/table/TableRow.tsx

Usage

import TableRow from '@proton/components/components/table/TableRow';

<TableRow>
  <TableCell>Cell 1</TableCell>
  <TableCell>Cell 2</TableCell>
</TableRow>

Props

className
string
Additional CSS classes
onClick
() => void
Click handler for clickable rows

TableCell

Table data cell. Location: components/table/TableCell.tsx

Usage

import TableCell from '@proton/components/components/table/TableCell';

<TableCell>Cell content</TableCell>

Props

className
string
Additional CSS classes
colSpan
number
Number of columns to span
rowSpan
number
Number of rows to span

TableHeaderCell

Table header cell. Location: components/table/TableHeaderCell.tsx

Usage

import TableHeaderCell from '@proton/components/components/table/TableHeaderCell';

<TableHeaderCell>Header</TableHeaderCell>

Props

className
string
Additional CSS classes
colSpan
number
Number of columns to span

SortingTableHeader

Header cell with sorting functionality. Location: components/table/SortingTableHeader.tsx

Usage

import SortingTableHeader from '@proton/components/components/table/SortingTableHeader';
import { useState } from 'react';

const MySortableTable = () => {
  const [sortField, setSortField] = useState('name');
  const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');

  const handleSort = (field: string) => {
    if (sortField === field) {
      setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
    } else {
      setSortField(field);
      setSortDirection('asc');
    }
  };

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <SortingTableHeader
            onSort={() => handleSort('name')}
            active={sortField === 'name'}
            direction={sortDirection}
          >
            Name
          </SortingTableHeader>
          <SortingTableHeader
            onSort={() => handleSort('email')}
            active={sortField === 'email'}
            direction={sortDirection}
          >
            Email
          </SortingTableHeader>
        </TableRow>
      </TableHeader>
      <TableBody>
        {/* Sorted data rows */}
      </TableBody>
    </Table>
  );
};

Props

onSort
() => void
required
Sort handler function
active
boolean
Whether this column is currently sorted
direction
'asc' | 'desc'
Sort direction when active

TableRowBusy

Row with loading state. Location: components/table/TableRowBusy.tsx

Usage

import TableRowBusy from '@proton/components/components/table/TableRowBusy';

<TableBody>
  {loading ? (
    <TableRowBusy colSpan={3} />
  ) : (
    data.map(item => <TableRow key={item.id}>...</TableRow>)
  )}
</TableBody>

TableCellBusy

Cell with loading state. Location: components/table/TableCellBusy.tsx

Usage

import TableCellBusy from '@proton/components/components/table/TableCellBusy';

<TableCell>
  {loading ? <TableCellBusy /> : data}
</TableCell>

Best Practices

Sorting Implementation

const [sortConfig, setSortConfig] = useState<{
  key: string;
  direction: 'asc' | 'desc';
}>({ key: 'name', direction: 'asc' });

const sortedData = useMemo(() => {
  const sorted = [...data].sort((a, b) => {
    if (a[sortConfig.key] < b[sortConfig.key]) {
      return sortConfig.direction === 'asc' ? -1 : 1;
    }
    if (a[sortConfig.key] > b[sortConfig.key]) {
      return sortConfig.direction === 'asc' ? 1 : -1;
    }
    return 0;
  });
  return sorted;
}, [data, sortConfig]);

const handleSort = (key: string) => {
  setSortConfig({
    key,
    direction:
      sortConfig.key === key && sortConfig.direction === 'asc'
        ? 'desc'
        : 'asc',
  });
};

Responsive Tables

// Use responsive="cards" for mobile-friendly card layout
<Table responsive="cards">
  <TableHeader>
    <TableRow>
      <TableHeaderCell>Name</TableHeaderCell>
      <TableHeaderCell>Email</TableHeaderCell>
      <TableHeaderCell>Status</TableHeaderCell>
    </TableRow>
  </TableHeader>
  <TableBody>
    {users.map(user => (
      <TableRow key={user.id}>
        <TableCell data-label="Name">{user.name}</TableCell>
        <TableCell data-label="Email">{user.email}</TableCell>
        <TableCell data-label="Status">{user.status}</TableCell>
      </TableRow>
    ))}
  </TableBody>
</Table>

Loading States

<Table>
  <TableHeader>
    <TableRow>
      <TableHeaderCell>Name</TableHeaderCell>
      <TableHeaderCell>Email</TableHeaderCell>
    </TableRow>
  </TableHeader>
  <TableBody>
    {loading ? (
      <TableRowBusy colSpan={2} />
    ) : data.length === 0 ? (
      <TableRow>
        <TableCell colSpan={2} className="text-center">
          No data available
        </TableCell>
      </TableRow>
    ) : (
      data.map(item => (
        <TableRow key={item.id}>
          <TableCell>{item.name}</TableCell>
          <TableCell>{item.email}</TableCell>
        </TableRow>
      ))
    )}
  </TableBody>
</Table>

Accessibility

// Provide table caption for screen readers
<Table caption="User accounts list">
  {/* Table content */}
</Table>

// Use proper scope for headers
<TableHeaderCell scope="col">Name</TableHeaderCell>
<TableHeaderCell scope="col">Email</TableHeaderCell>

// Provide aria-labels for sortable columns
<SortingTableHeader
  onSort={handleSort}
  aria-label={`Sort by name ${sortDirection === 'asc' ? 'descending' : 'ascending'}`}
>
  Name
</SortingTableHeader>

Row Actions

<Table hasActions>
  <TableHeader>
    <TableRow>
      <TableHeaderCell>Name</TableHeaderCell>
      <TableHeaderCell>Email</TableHeaderCell>
      <TableHeaderCell>Actions</TableHeaderCell>
    </TableRow>
  </TableHeader>
  <TableBody>
    {data.map(item => (
      <TableRow key={item.id}>
        <TableCell>{item.name}</TableCell>
        <TableCell>{item.email}</TableCell>
        <TableCell>
          <div className="flex gap-2">
            <Button
              size="small"
              shape="ghost"
              onClick={() => handleEdit(item)}
            >
              Edit
            </Button>
            <Button
              size="small"
              shape="ghost"
              color="danger"
              onClick={() => handleDelete(item)}
            >
              Delete
            </Button>
          </div>
        </TableCell>
      </TableRow>
    ))}
  </TableBody>
</Table>

Common Patterns

Selectable Rows

const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());

const toggleSelect = (id: string) => {
  const newSelected = new Set(selectedIds);
  if (newSelected.has(id)) {
    newSelected.delete(id);
  } else {
    newSelected.add(id);
  }
  setSelectedIds(newSelected);
};

const toggleSelectAll = () => {
  if (selectedIds.size === data.length) {
    setSelectedIds(new Set());
  } else {
    setSelectedIds(new Set(data.map(item => item.id)));
  }
};

return (
  <Table>
    <TableHeader>
      <TableRow>
        <TableHeaderCell>
          <Checkbox
            checked={selectedIds.size === data.length}
            indeterminate={selectedIds.size > 0 && selectedIds.size < data.length}
            onChange={toggleSelectAll}
          />
        </TableHeaderCell>
        <TableHeaderCell>Name</TableHeaderCell>
      </TableRow>
    </TableHeader>
    <TableBody>
      {data.map(item => (
        <TableRow key={item.id}>
          <TableCell>
            <Checkbox
              checked={selectedIds.has(item.id)}
              onChange={() => toggleSelect(item.id)}
            />
          </TableCell>
          <TableCell>{item.name}</TableCell>
        </TableRow>
      ))}
    </TableBody>
  </Table>
);

Expandable Rows

const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());

const toggleExpand = (id: string) => {
  const newExpanded = new Set(expandedIds);
  if (newExpanded.has(id)) {
    newExpanded.delete(id);
  } else {
    newExpanded.add(id);
  }
  setExpandedIds(newExpanded);
};

return (
  <Table>
    <TableBody>
      {data.map(item => (
        <>
          <TableRow key={item.id}>
            <TableCell>
              <Button
                icon
                size="small"
                onClick={() => toggleExpand(item.id)}
              >
                <Icon name={expandedIds.has(item.id) ? 'chevron-down' : 'chevron-right'} />
              </Button>
            </TableCell>
            <TableCell>{item.name}</TableCell>
          </TableRow>
          {expandedIds.has(item.id) && (
            <TableRow>
              <TableCell colSpan={2}>
                <div className="p-4">
                  {/* Expanded content */}
                </div>
              </TableCell>
            </TableRow>
          )}
        </>
      ))}
    </TableBody>
  </Table>
);

Source Code

View source:
  • Table: packages/components/components/table/Table.tsx:1
  • SortingTableHeader: packages/components/components/table/SortingTableHeader.tsx:1
  • TableRow: packages/components/components/table/TableRow.tsx:1

Build docs developers (and LLMs) love