A fully interactive data table with sortable columns, selectable rows, bulk actions, badge cells, and loading skeletons. Inspired by Notion and Linear's clean table aesthetic.
| Joined | ||||||
|---|---|---|---|---|---|---|
SCSarah Chen | [email protected] | Admin | Active | 3 months ago | ||
MJMarcus Johnson | [email protected] | Member | Active | 1 month ago | ||
PPPriya Patel | [email protected] | Member | Active | 2 weeks ago | ||
ARAlex Rivera | [email protected] | Viewer | Inactive | 5 months ago | ||
JKJordan Kim | [email protected] | Member | Active | 1 week ago | ||
TWTaylor Wong | [email protected] | Admin | Active | 6 months ago | ||
SOSam Okafor | [email protected] | Viewer | Pending | 2 days ago | ||
RPRiley Park | [email protected] | Member | Active | 3 weeks ago |
// Dependencies: react ^18, lucide-react
import React, { useState, useMemo } from 'react';
import { ChevronUp, ChevronDown, MoreHorizontal, Trash2, Download, X } from 'lucide-react';
export type Column = {
id: string;
label: string;
type: 'text' | 'badge' | 'person' | 'date' | 'number' | 'actions';
sortable?: boolean;
width?: number;
};
export type Row = {
id: string;
cells: Record<string, any>;
};
type Props = {
columns: Column[];
rows: Row[];
selectable?: boolean;
sortable?: boolean;
loading?: boolean;
onRowClick?: (row: Row) => void;
};
const BADGE_COLORS: Record<string, string> = {
Admin: 'bg-purple-500/15 text-purple-300 border-purple-500/20',
Member: 'bg-blue-500/15 text-blue-300 border-blue-500/20',
Viewer: 'bg-zinc-800 text-zinc-400 border-zinc-700/50',
Active: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/20',
Inactive: 'bg-zinc-800 text-zinc-500 border-zinc-700/50',
Pending: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/20',
};
function Badge({ value }: { value: string }) {
const cls = BADGE_COLORS[value] ?? 'bg-zinc-800 text-zinc-400 border-zinc-700/50';
return <span className={"text-[11px] px-2 py-0.5 rounded-full border font-medium " + cls}>{value}</span>;
}
function PersonCell({ value }: { value: { name: string; initials: string; color: string } }) {
return (
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0" style={{ backgroundColor: value.color }}>{value.initials}</span>
<span className="text-[13px] text-zinc-300">{value.name}</span>
</div>
);
}
type SortState = { column: string; direction: 'asc' | 'desc' } | null;
export function InlineDataTable({ columns, rows, selectable, sortable, loading, onRowClick }: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<SortState>(null);
const toggleRow = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
const toggleAll = () => { if (selected.size === rows.length) setSelected(new Set()); else setSelected(new Set(rows.map((r) => r.id))); };
const handleSort = (colId: string) => { if (!sortable) return; setSort((prev) => { if (prev?.column !== colId) return { column: colId, direction: 'asc' }; if (prev.direction === 'asc') return { column: colId, direction: 'desc' }; return null; }); };
const sortedRows = useMemo(() => {
if (!sort) return rows;
return [...rows].sort((a, b) => {
const cmp = String(a.cells[sort.column] ?? '').localeCompare(String(b.cells[sort.column] ?? ''));
return sort.direction === 'asc' ? cmp : -cmp;
});
}, [rows, sort]);
const allSelected = rows.length > 0 && selected.size === rows.length;
return (
<div className="relative flex flex-col h-full">
<div className="flex-1 overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10">
<tr className="border-b border-white/[0.08] bg-[#111]">
{selectable && (
<th className="w-10 px-3 py-2.5">
<input type="checkbox" checked={allSelected} onChange={toggleAll} className="w-3.5 h-3.5 rounded border border-zinc-600 bg-zinc-800 cursor-pointer accent-indigo-500" />
</th>
)}
{columns.map((col) => (
<th key={col.id} className="px-3 py-2.5 text-[11px] font-semibold text-zinc-500 tracking-wide uppercase whitespace-nowrap" style={col.width ? { width: col.width } : undefined}>
{sortable && col.sortable !== false && col.type !== 'actions' ? (
<button onClick={() => handleSort(col.id)} className="flex items-center gap-1 hover:text-zinc-300 transition-colors">
{col.label}
<span className="flex flex-col -space-y-0.5 ml-0.5">
<ChevronUp className={"w-2.5 h-2.5 " + (sort?.column === col.id && sort.direction === 'asc' ? 'text-indigo-400' : 'text-zinc-700')} />
<ChevronDown className={"w-2.5 h-2.5 " + (sort?.column === col.id && sort.direction === 'desc' ? 'text-indigo-400' : 'text-zinc-700')} />
</span>
</button>
) : col.label}
</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
[0, 1, 2, 3].map((i) => (
<tr key={i} className="border-b border-white/[0.04]">
{selectable && <td className="w-10 px-3 py-3"><div className="w-3.5 h-3.5 rounded bg-zinc-800 animate-pulse" /></td>}
{columns.map((col) => <td key={col.id} className="px-3 py-3"><div className="h-3 rounded bg-zinc-800 animate-pulse" style={{ width: '60%' }} /></td>)}
</tr>
))
) : sortedRows.length === 0 ? (
<tr><td colSpan={columns.length + (selectable ? 1 : 0)} className="px-3 py-12 text-center text-sm text-zinc-600">No data</td></tr>
) : sortedRows.map((row) => (
<tr key={row.id} className={"group border-b border-white/[0.04] last:border-0 transition-colors " + (onRowClick ? 'cursor-pointer ' : '') + (selected.has(row.id) ? 'bg-indigo-500/[0.06]' : 'hover:bg-white/[0.02]')} onClick={() => onRowClick?.(row)}>
{selectable && (
<td className="w-10 px-3 py-2.5" onClick={(e) => { e.stopPropagation(); toggleRow(row.id); }}>
<input type="checkbox" checked={selected.has(row.id)} onChange={() => toggleRow(row.id)} className="w-3.5 h-3.5 rounded border border-zinc-600 bg-zinc-800 cursor-pointer accent-indigo-500" />
</td>
)}
{columns.map((col) => (
<td key={col.id} className="px-3 py-2.5">
{col.type === 'badge' ? <Badge value={row.cells[col.id]} />
: col.type === 'person' ? <PersonCell value={row.cells[col.id]} />
: col.type === 'date' ? <span className="text-[13px] text-zinc-500">{row.cells[col.id]}</span>
: col.type === 'number' ? <span className="text-[13px] text-zinc-300 font-mono">{row.cells[col.id]}</span>
: col.type === 'actions' ? (
<button className="p-1 rounded hover:bg-white/[0.08] text-zinc-600 hover:text-zinc-300 transition-colors opacity-0 group-hover:opacity-100" onClick={(e) => e.stopPropagation()}>
<MoreHorizontal className="w-4 h-4" />
</button>
) : <span className="text-[13px] text-zinc-300">{row.cells[col.id]}</span>}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{selected.size > 0 && (
<div className="shrink-0 flex items-center gap-3 px-4 py-2.5 bg-zinc-900/90 border-t border-white/[0.08]">
<span className="text-[12px] text-zinc-400">{selected.size} {selected.size === 1 ? 'row' : 'rows'} selected</span>
<div className="h-3 w-px bg-zinc-700" />
<button className="flex items-center gap-1.5 text-[12px] text-red-400 hover:text-red-300 transition-colors"><Trash2 className="w-3.5 h-3.5" />Delete</button>
<button className="flex items-center gap-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 transition-colors"><Download className="w-3.5 h-3.5" />Export</button>
<button className="ml-auto text-zinc-600 hover:text-zinc-300 transition-colors" onClick={() => setSelected(new Set())}><X className="w-3.5 h-3.5" /></button>
</div>
)}
</div>
);
}Unlock to copy
Free access to all patterns
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Sparkline Trend Cards
Linear
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Bottom Sheet
Stripe
Toast / Snackbar Stack
Vercel
Notification Center
Linear
Issue / Task Card
Linear
Subtask Checklist
Linear
Kanban Board Column
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
File Upload Dropzone
Vercel
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Row Detail Drawer
Stripe
Editable Cell
Notion
Status Badge System
Linear
Segmented Control
Notion