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