Skip to main content

Data tables

Recipes for building data-rich tables — sorting, filtering, pagination, row selection, and responsive layouts. Each demo is production-ready.

Basic Table

A minimal table with headers and rows. Use semantic markup and consistent cell padding for clean data presentation.

NameEmailRoleStatus
Sarah Chensarah.chen@company.comAdminActive
Marcus Johnsonmarcus.j@company.comEditorActive
Aisha Patelaisha.p@company.comViewerPending
David Kimdavid.kim@company.comAdminActive
Elena Rodriguezelena.r@company.comEditorInactive
basic-table.tsx
tsx
<table className="w-full text-sm">
  <thead>
    <tr>
      <th className="text-left py-3 px-4 font-medium
        text-(--text-muted) border-b border-(--border)">
        Name
      </th>
      {/* ...more columns */}
    </tr>
  </thead>
  <tbody>
    {users.map(user => (
      <tr key={user.id} className="hover:bg-(--surface-hover)">
        <td className="py-3 px-4 text-(--text)
          border-b border-(--border)/40">
          {user.name}
        </td>
      </tr>
    ))}
  </tbody>
</table>

Sortable Columns

Make column headers clickable to sort rows. Track the active column and direction in state, then sort the data before rendering.

NameEmailRoleStatus
Aisha Patelaisha.p@company.comViewerPending
David Kimdavid.kim@company.comAdminActive
Elena Rodriguezelena.r@company.comEditorInactive
James Wrightjames.w@company.comViewerActive
Marcus Johnsonmarcus.j@company.comEditorActive
Priya Sharmapriya.s@company.comEditorPending
Sarah Chensarah.chen@company.comAdminActive

Click any column header to sort. Click again to reverse direction.

sortable-table.tsx
tsx
const [sortColumn, setSortColumn] = useState('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');

const sorted = useMemo(() =>
  [...users].sort((a, b) => {
    const cmp = a[sortColumn].localeCompare(b[sortColumn]);
    return sortDirection === 'asc' ? cmp : -cmp;
  }), [sortColumn, sortDirection]
);

<th onClick={() => handleSort('name')}
    className="cursor-pointer select-none">
  Name {sortColumn === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>

Searchable Table

Add a search input to filter rows in real time. Use useMemo to filter data based on the query without re-rendering the entire table.

NameEmailRoleStatus
Sarah Chensarah.chen@company.comAdminActive
Marcus Johnsonmarcus.j@company.comEditorActive
Aisha Patelaisha.p@company.comViewerPending
David Kimdavid.kim@company.comAdminActive
Elena Rodriguezelena.r@company.comEditorInactive
James Wrightjames.w@company.comViewerActive
Priya Sharmapriya.s@company.comEditorPending

7 of 7 rows shown

searchable-table.tsx
tsx
const [query, setQuery] = useState('');

const filtered = useMemo(() => {
  if (!query.trim()) return users;
  const q = query.toLowerCase();
  return users.filter(u =>
    u.name.toLowerCase().includes(q) ||
    u.email.toLowerCase().includes(q)
  );
}, [query]);

<input
  type="text"
  placeholder="Search..."
  value={query}
  onChange={e => setQuery(e.target.value)}
  className="..."
/>

Pagination

Split large datasets into pages. Track the current page in state and slice the data array accordingly.

NameEmailRoleDate
Sarah Chensarah.chen@company.comAdmin2026-01-15
Marcus Johnsonmarcus.j@company.comEditor2026-02-20
Aisha Patelaisha.p@company.comViewer2026-03-10

Showing 13 of 7

pagination.tsx
tsx
const [page, setPage] = useState(1);
const pageSize = 10;
const totalPages = Math.ceil(data.length / pageSize);

const pageData = useMemo(() => {
  const start = (page - 1) * pageSize;
  return data.slice(start, start + pageSize);
}, [page, data]);

<div className="flex items-center gap-1">
  <button onClick={() => setPage(p => Math.max(1, p - 1))}
    disabled={page === 1}>Prev</button>
  {Array.from({ length: totalPages }, (_, i) => (
    <button key={i} onClick={() => setPage(i + 1)}
      className={page === i + 1 ? 'bg-brand-500 text-white' : ''}>
      {i + 1}
    </button>
  ))}
  <button onClick={() => setPage(p => Math.min(totalPages, p + 1))}
    disabled={page === totalPages}>Next</button>
</div>

Row Selection

Add checkboxes for selecting individual rows or all rows at once. Track selected IDs in a Set for O(1) lookups.

NameEmailRoleStatus
Sarah Chensarah.chen@company.comAdminActive
Marcus Johnsonmarcus.j@company.comEditorActive
Aisha Patelaisha.p@company.comViewerPending
David Kimdavid.kim@company.comAdminActive
Elena Rodriguezelena.r@company.comEditorInactive
James Wrightjames.w@company.comViewerActive
Priya Sharmapriya.s@company.comEditorPending
row-selection.tsx
tsx
const [selected, setSelected] = useState<Set<number>>(new Set());

function toggleAll() {
  if (selected.size === data.length) setSelected(new Set());
  else setSelected(new Set(data.map(d => d.id)));
}

function toggle(id: number) {
  setSelected(prev => {
    const next = new Set(prev);
    next.has(id) ? next.delete(id) : next.add(id);
    return next;
  });
}

<th>
  <input type="checkbox"
    checked={selected.size === data.length}
    onChange={toggleAll} />
</th>
// ...
<td>
  <input type="checkbox"
    checked={selected.has(row.id)}
    onChange={() => toggle(row.id)} />
</td>

Status Cells

Display status with colored badges that communicate state at a glance. Map each status value to a color scheme.

ActiveGreen for active/success
InactiveGray for inactive/disabled
PendingYellow for pending/warning
NameRoleStatus
Sarah ChenAdminActive
Marcus JohnsonEditorActive
Aisha PatelViewerPending
David KimAdminActive
Elena RodriguezEditorInactive
James WrightViewerActive
Priya SharmaEditorPending
status-badge.tsx
tsx
const statusStyles = {
  Active:   'bg-success-500/10 text-success-500 border-success-500/20',
  Inactive: 'bg-neutral-500/10 text-neutral-400 border-neutral-500/20',
  Pending:  'bg-warn-500/10 text-warn-500 border-warn-500/20',
};

function StatusBadge({ status }: { status: string }) {
  return (
    <span className={`inline-flex items-center gap-1.5
      px-2.5 py-0.5 text-[10px] font-semibold rounded-lg
      border ${statusStyles[status]}`}>
      <span className="w-1.5 h-1.5 rounded-full bg-current" />
      {status}
    </span>
  );
}

Actions Column

Put each row's actions behind a dropdown menu and pin the column to the right edge, so it stays reachable while the rest of the table scrolls horizontally.

NameEmailRoleStatusJoinedActions
Sarah Chensarah.chen@company.comAdminActive2026-01-15
Marcus Johnsonmarcus.j@company.comEditorActive2026-02-20
Aisha Patelaisha.p@company.comViewerPending2026-03-10
David Kimdavid.kim@company.comAdminActive2026-04-05
Elena Rodriguezelena.r@company.comEditorInactive2026-05-18

Scroll the table sideways — the actions column stays pinned and shows a divider once content passes underneath it.

actions-column.tsx
tsx
import { Table, TableRowActions, type TableColumn } from '@/components/aidash/table';

const columns = [
  { key: 'name', header: 'Name' },
  { key: 'email', header: 'Email' },
  { key: 'role', header: 'Role' },
  { key: 'department', header: 'Department' },
  {
    key: 'actions',
    header: 'Actions',
    align: 'right',
    width: '72px',
    sticky: 'right',        // pinned while the table scrolls sideways
    render: (_value, row) => (
      <TableRowActions
        label={`Actions for ${row.name}`}
        items={[
          { label: 'Edit', icon: <HugeiconsIcon icon={PencilEdit01Icon} size={14} />, onClick: () => onEdit(row) },
          { label: 'Duplicate', icon: <HugeiconsIcon icon={Copy01Icon} size={14} />, onClick: () => onDuplicate(row) },
          { separator: true, label: '' },
          { label: 'Delete', icon: <HugeiconsIcon icon={Delete01Icon} size={14} />, danger: true, onClick: () => onDelete(row) },
        ]}
      />
    ),
  },
];

<Table columns={columns} data={users} />
Tip
A menu scales past the two or three actions that fit as bare icon buttons, and keeps the pinned column narrow. TableRowActions portals its menu, so the dropdown is never clipped by the table's scroll container.

Responsive Tables

Tables can be tricky on small screens. Two main approaches: horizontal scroll for data integrity, or card layout for readability.

Approach 1: Horizontal Scroll

Wrap the table in an overflow container. The table stays intact while users scroll horizontally on small screens.

NameEmailRoleStatus
Sarah Chensarah.chen@company.comAdminActive
Marcus Johnsonmarcus.j@company.comEditorActive
Aisha Patelaisha.p@company.comViewerPending

Approach 2: Card Layout

On mobile, transform table rows into stacked cards. Show column labels alongside values.

Sarah ChenActive
Email

sarah.chen@company.com

Role

Admin

Marcus JohnsonActive
Email

marcus.j@company.com

Role

Editor

responsive-table.tsx
tsx
{/* Approach 1: Horizontal scroll */}
<div className="overflow-x-auto">
  <table className="w-full min-w-[600px]">
    {/* normal table markup */}
  </table>
</div>

{/* Approach 2: Cards on mobile */}
<div className="hidden md:block">
  <table>{/* normal table */}</table>
</div>
<div className="md:hidden space-y-3">
  {data.map(row => (
    <div key={row.id} className="rounded-xl border p-4">
      <div className="flex justify-between">
        <span className="font-medium">{row.name}</span>
        <StatusBadge status={row.status} />
      </div>
      <div className="grid grid-cols-2 gap-2 text-xs mt-2">
        <div>
          <span className="text-muted">Email</span>
          <p>{row.email}</p>
        </div>
      </div>
    </div>
  ))}
</div>

Best Practices

Guidelines for building accessible, performant, and user-friendly data tables.

Do

  • Use consistent column alignment (left for text, right for numbers)
  • Add hover states on rows for scannability
  • Show loading skeletons while data fetches
  • Provide an empty state message when no data matches
  • Use semantic <table>, <thead>, <tbody> elements
  • Paginate large datasets (50+ rows) for performance

Don't

  • Render hundreds of rows without virtualization
  • Use divs to simulate table layout (accessibility issues)
  • Hide data columns on mobile without alternative access
  • Make the entire row clickable without visual affordance
  • Sort client-side when dealing with server-paginated data
  • Overload tables with too many action buttons per row

Components that work together with data table patterns.