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.
| Name | Role | Status | |
|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | Active |
| Marcus Johnson | marcus.j@company.com | Editor | Active |
| Aisha Patel | aisha.p@company.com | Viewer | Pending |
| David Kim | david.kim@company.com | Admin | Active |
| Elena Rodriguez | elena.r@company.com | Editor | Inactive |
<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.
| Name | Role | Status | |
|---|---|---|---|
| Aisha Patel | aisha.p@company.com | Viewer | Pending |
| David Kim | david.kim@company.com | Admin | Active |
| Elena Rodriguez | elena.r@company.com | Editor | Inactive |
| James Wright | james.w@company.com | Viewer | Active |
| Marcus Johnson | marcus.j@company.com | Editor | Active |
| Priya Sharma | priya.s@company.com | Editor | Pending |
| Sarah Chen | sarah.chen@company.com | Admin | Active |
Click any column header to sort. Click again to reverse direction.
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.
| Name | Role | Status | |
|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | Active |
| Marcus Johnson | marcus.j@company.com | Editor | Active |
| Aisha Patel | aisha.p@company.com | Viewer | Pending |
| David Kim | david.kim@company.com | Admin | Active |
| Elena Rodriguez | elena.r@company.com | Editor | Inactive |
| James Wright | james.w@company.com | Viewer | Active |
| Priya Sharma | priya.s@company.com | Editor | Pending |
7 of 7 rows shown
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.
| Name | Role | Date | |
|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | 2026-01-15 |
| Marcus Johnson | marcus.j@company.com | Editor | 2026-02-20 |
| Aisha Patel | aisha.p@company.com | Viewer | 2026-03-10 |
Showing 1–3 of 7
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.
| Name | Role | Status | ||
|---|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | Active | |
| Marcus Johnson | marcus.j@company.com | Editor | Active | |
| Aisha Patel | aisha.p@company.com | Viewer | Pending | |
| David Kim | david.kim@company.com | Admin | Active | |
| Elena Rodriguez | elena.r@company.com | Editor | Inactive | |
| James Wright | james.w@company.com | Viewer | Active | |
| Priya Sharma | priya.s@company.com | Editor | Pending |
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.
| Name | Role | Status |
|---|---|---|
| Sarah Chen | Admin | Active |
| Marcus Johnson | Editor | Active |
| Aisha Patel | Viewer | Pending |
| David Kim | Admin | Active |
| Elena Rodriguez | Editor | Inactive |
| James Wright | Viewer | Active |
| Priya Sharma | Editor | Pending |
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.
| Name | Role | Status | Joined | Actions | |
|---|---|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | Active | 2026-01-15 | |
| Marcus Johnson | marcus.j@company.com | Editor | Active | 2026-02-20 | |
| Aisha Patel | aisha.p@company.com | Viewer | Pending | 2026-03-10 | |
| David Kim | david.kim@company.com | Admin | Active | 2026-04-05 | |
| Elena Rodriguez | elena.r@company.com | Editor | Inactive | 2026-05-18 |
Scroll the table sideways — the actions column stays pinned and shows a divider once content passes underneath it.
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} />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.
| Name | Role | Status | |
|---|---|---|---|
| Sarah Chen | sarah.chen@company.com | Admin | Active |
| Marcus Johnson | marcus.j@company.com | Editor | Active |
| Aisha Patel | aisha.p@company.com | Viewer | Pending |
Approach 2: Card Layout
On mobile, transform table rows into stacked cards. Show column labels alongside values.
sarah.chen@company.com
Admin
marcus.j@company.com
Editor
{/* 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
Related
Components that work together with data table patterns.