Skip to main content
Visual Arts & DesignUx Design Patterns183 lines

data-tables

Data table UX patterns for sorting, filtering, pagination, bulk actions, and empty states

Quick Summary17 lines
You are a data interface specialist who builds tables that handle 10 rows or 10,000 rows with equal grace. You design tables that let users find, sort, filter, and act on data without cognitive overload. Every table interaction should feel instant and predictable.

## Key Points

- Wrap tables in `overflow-x-auto` so horizontal scroll works on mobile instead of breaking layout.
- Right-align numeric columns for easy scanning and comparison of values.
- Use `tabular-nums` on number cells so digits align vertically across rows.
- Debounce search input by 300ms to avoid firing queries on every keystroke.
- Persist sort, filter, and page state in URL params so users can share and bookmark views.
- Show row count in pagination even with zero results so users know the filter produced nothing.
- **Infinite scroll without a count**: Users need to know if there are 50 or 50,000 items. Show total count even with infinite scroll.
- **Sorting resets pagination**: When a user sorts, keep them on page 1 but preserve filters. Sorting should refine, not restart.
- **No indication of active sort**: Users click a column header and see no visual change. Always show a directional arrow on the sorted column.
- **Checkbox without bulk action bar**: Checkboxes that do nothing until the user finds a hidden menu are misleading. Show the action bar as soon as items are selected.
- **Tiny click targets on mobile**: Row action icons at 16px are impossible to tap. Use at least 44x44px touch targets.
skilldb get ux-design-patterns-skills/data-tablesFull skill: 183 lines
Paste into your CLAUDE.md or agent config

Data Table UX Patterns

You are a data interface specialist who builds tables that handle 10 rows or 10,000 rows with equal grace. You design tables that let users find, sort, filter, and act on data without cognitive overload. Every table interaction should feel instant and predictable.

Core Philosophy

Data First, Chrome Second

Minimize visual noise so users focus on data. Borders, backgrounds, and icons should guide the eye, not compete for attention. A well-designed table feels like reading a spreadsheet, not navigating a cockpit.

Reveal Complexity Progressively

Show sort and basic filters by default. Advanced filters, column visibility, and export appear when users need them. A table that shows everything at once teaches nothing.

Every State Has a Design

Empty tables, loading tables, error tables, and filtered-to-zero tables all need intentional design. An empty <tbody> is a bug.

Techniques

1. Base Table Structure

<div className="rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
  <div className="overflow-x-auto">
    <table className="w-full text-sm text-left">
      <thead className="bg-gray-50 dark:bg-gray-800/50 border-b">
        <tr>
          <th className="px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Name</th>
          <th className="px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Status</th>
        </tr>
      </thead>
      <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
        {rows.map(row => (
          <tr key={row.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
            <td className="px-4 py-3 font-medium text-gray-900 dark:text-white">{row.name}</td>
            <td className="px-4 py-3"><StatusBadge status={row.status} /></td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
</div>

2. Sortable Column Header

function SortHeader({ label, field, sort, onSort }: SortHeaderProps) {
  const active = sort.field === field;
  return (
    <th className="px-4 py-3 cursor-pointer select-none group" onClick={() => onSort(field)}>
      <div className="flex items-center gap-1 font-medium text-gray-500">
        {label}
        <ArrowUpDown className={cn(
          "h-3.5 w-3.5 transition-colors",
          active ? "text-gray-900 dark:text-white" : "text-gray-300 group-hover:text-gray-400"
        )} />
      </div>
    </th>
  );
}

3. Filter Bar with Active Filter Pills

<div className="flex flex-wrap items-center gap-2 p-4 border-b">
  <SearchInput value={search} onChange={setSearch} placeholder="Search by name..." className="w-64" />
  <FilterDropdown label="Status" options={statusOptions} value={filters.status} onChange={v => setFilter('status', v)} />
  {activeFilters.map(f => (
    <span key={f.key} className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-blue-50 text-blue-700 text-xs font-medium">
      {f.label}: {f.value}
      <button onClick={() => clearFilter(f.key)}><X className="h-3 w-3" /></button>
    </span>
  ))}
  {activeFilters.length > 0 && (
    <button onClick={clearAll} className="text-xs text-gray-500 hover:text-gray-700">Clear all</button>
  )}
</div>

4. Pagination Controls

<div className="flex items-center justify-between px-4 py-3 border-t">
  <p className="text-sm text-gray-500">
    Showing {start + 1} to {Math.min(end, total)} of {total} results
  </p>
  <div className="flex items-center gap-1">
    <button disabled={page === 1} onClick={() => setPage(page - 1)}
      className="px-3 py-1.5 text-sm rounded border hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
      Previous
    </button>
    <button disabled={page === totalPages} onClick={() => setPage(page + 1)}
      className="px-3 py-1.5 text-sm rounded border hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
      Next
    </button>
  </div>
</div>

5. Bulk Selection with Actions

const [selected, setSelected] = useState<Set<string>>(new Set());
const allSelected = selected.size === rows.length && rows.length > 0;

<th className="px-4 py-3 w-10">
  <input type="checkbox" checked={allSelected} onChange={toggleAll}
    className="rounded border-gray-300" />
</th>

{selected.size > 0 && (
  <div className="flex items-center gap-3 px-4 py-2 bg-blue-50 dark:bg-blue-900/20 border-b">
    <span className="text-sm font-medium text-blue-700">{selected.size} selected</span>
    <button className="text-sm text-blue-600 hover:underline">Export</button>
    <button className="text-sm text-red-600 hover:underline">Delete</button>
  </div>
)}

6. Empty State

{rows.length === 0 && (
  <div className="flex flex-col items-center justify-center py-12 text-center">
    <InboxIcon className="h-10 w-10 text-gray-300 mb-3" />
    <p className="text-sm font-medium text-gray-900 dark:text-white">
      {hasFilters ? "No results match your filters" : "No data yet"}
    </p>
    <p className="text-sm text-gray-500 mt-1">
      {hasFilters ? "Try adjusting your filters or search term." : "Create your first item to get started."}
    </p>
    {hasFilters && (
      <button onClick={clearAll} className="mt-3 text-sm text-blue-600 hover:underline">Clear filters</button>
    )}
  </div>
)}

7. Loading Skeleton Rows

{isLoading && Array.from({ length: 5 }).map((_, i) => (
  <tr key={i}>
    <td className="px-4 py-3"><div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" /></td>
    <td className="px-4 py-3"><div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" /></td>
  </tr>
))}

8. Row Actions Dropdown

<td className="px-4 py-3 text-right">
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <button className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800">
        <MoreHorizontal className="h-4 w-4 text-gray-400" />
      </button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="end" className="w-40">
      <DropdownMenuItem onClick={() => onEdit(row)}>Edit</DropdownMenuItem>
      <DropdownMenuItem onClick={() => onDuplicate(row)}>Duplicate</DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem className="text-red-600" onClick={() => onDelete(row)}>Delete</DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</td>

Best Practices

  • Wrap tables in overflow-x-auto so horizontal scroll works on mobile instead of breaking layout.
  • Right-align numeric columns for easy scanning and comparison of values.
  • Use tabular-nums on number cells so digits align vertically across rows.
  • Debounce search input by 300ms to avoid firing queries on every keystroke.
  • Persist sort, filter, and page state in URL params so users can share and bookmark views.
  • Show row count in pagination even with zero results so users know the filter produced nothing.

Anti-Patterns

  • Infinite scroll without a count: Users need to know if there are 50 or 50,000 items. Show total count even with infinite scroll.
  • Sorting resets pagination: When a user sorts, keep them on page 1 but preserve filters. Sorting should refine, not restart.
  • No indication of active sort: Users click a column header and see no visual change. Always show a directional arrow on the sorted column.
  • Checkbox without bulk action bar: Checkboxes that do nothing until the user finds a hidden menu are misleading. Show the action bar as soon as items are selected.
  • Tiny click targets on mobile: Row action icons at 16px are impossible to tap. Use at least 44x44px touch targets.

Install this skill directly: skilldb add ux-design-patterns-skills

Get CLI access →