data-tables
Data table UX patterns for sorting, filtering, pagination, bulk actions, and empty states
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 linesData 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-autoso horizontal scroll works on mobile instead of breaking layout. - Right-align numeric columns for easy scanning and comparison of values.
- Use
tabular-numson 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
Related Skills
dashboard-layout
Dashboard layout patterns with sidebar nav, header, content area, and responsive breakpoints
form-patterns
Form design patterns for validation, multi-step forms, inline editing, and error handling
modal-patterns
Modal and dialog UX patterns for confirmations, form modals, drawers, and command palettes
navigation-patterns
Navigation patterns including breadcrumbs, tabs, command palette, and sidebar collapse
notification-system
Toast, banner, badge, and inbox-style notification patterns
onboarding-flows
User onboarding patterns with setup wizards, progressive disclosure, and empty states