A dense data table with sortable column headers (click to sort asc/desc with an indigo caret), per-row checkboxes, a header select-all that goes indeterminate when partially selected, and a bulk-action bar that appears on selection. Status and priority badges are inlined via token records. Inspired by Linear.
| MOD-001 | Fix authentication flow on mobile devices | In Progress | High | Sarah Chen | 2024-01-15 | |
| MOD-002 | Implement dark mode for settings panel | Todo | Medium | Marcus Johnson | 2024-01-14 | |
| MOD-003 | Database query optimization for reports page | In Review | Urgent | Priya Patel | 2024-01-13 | |
| MOD-004 | Add CSV export to analytics page | Done | Low | Alex Rivera | 2024-01-12 | |
| MOD-005 | Update rate limiting middleware for API v2 | Blocked | High | Sarah Chen | 2024-01-11 | |
| MOD-006 | Redesign onboarding flow for new workspace members | Todo | Medium | Marcus Johnson | 2024-01-10 | |
| MOD-007 | Fix memory leak in WebSocket event handler | In Progress | Urgent | Priya Patel | 2024-01-09 |
Click column headers to sort asc/desc · Check rows to select · Bulk action bar appears when rows are selected
// Dependencies: react ^18, lucide-react
import React, { useState, useMemo } from 'react';
import { ChevronUp, ChevronDown, ArrowUpDown, Trash2, Download, X } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
type Status = 'todo' | 'in-progress' | 'in-review' | 'done' | 'blocked';
type Priority = 'urgent' | 'high' | 'medium' | 'low';
const STATUS_TOKENS: Record<Status, { label: string; dot: string; bg: string; border: string; text: string }> = {
todo: { label: 'Todo', dot: 'bg-zinc-400', bg: 'bg-zinc-500/10', border: 'border-zinc-500/20', text: 'text-zinc-300' },
'in-progress':{ label: 'In Progress', dot: 'bg-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-300' },
'in-review': { label: 'In Review', dot: 'bg-indigo-400', bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-300' },
done: { label: 'Done', dot: 'bg-emerald-400',bg: 'bg-emerald-500/10',border: 'border-emerald-500/20',text: 'text-emerald-300' },
blocked: { label: 'Blocked', dot: 'bg-red-400', bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-300' },
};
const PRIORITY_TOKENS: Record<Priority, { label: string; bg: string; border: string; text: string }> = {
urgent: { label: 'Urgent', bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-300' },
high: { label: 'High', bg: 'bg-orange-500/10', border: 'border-orange-500/20', text: 'text-orange-300' },
medium: { label: 'Medium', bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-300' },
low: { label: 'Low', bg: 'bg-zinc-500/10', border: 'border-zinc-500/20', text: 'text-zinc-400' },
};
export type Issue = {
id: string;
title: string;
status: Status;
priority: Priority;
assignee: string;
date: string;
};
type SortKey = keyof Issue;
type SortDir = 'asc' | 'desc';
type Props = {
issues: Issue[];
defaultSort?: { key: SortKey; dir: SortDir };
};
const COLUMNS: { key: SortKey; label: string }[] = [
{ key: 'id', label: 'ID' },
{ key: 'title', label: 'Title' },
{ key: 'status', label: 'Status' },
{ key: 'priority', label: 'Priority' },
{ key: 'assignee', label: 'Assignee' },
{ key: 'date', label: 'Date' },
];
export function SortableTable({ issues, defaultSort = { key: 'date', dir: 'desc' } }: Props) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>(defaultSort);
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 allSelected = issues.length > 0 && selected.size === issues.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () => {
if (allSelected) setSelected(new Set());
else setSelected(new Set(issues.map(i => i.id)));
};
const handleSort = (key: SortKey) => {
setSort(prev => ({ key, dir: prev.key === key && prev.dir === 'asc' ? 'desc' : 'asc' }));
};
const sorted = useMemo(
() => [...issues].sort((a, b) => {
const cmp = String(a[sort.key]).localeCompare(String(b[sort.key]));
return sort.dir === 'asc' ? cmp : -cmp;
}),
[issues, sort]
);
return (
<div className="flex flex-col h-full">
{selected.size > 0 && (
<div className="shrink-0 flex items-center gap-3 px-4 py-2 bg-indigo-500/[0.08] border-b border-indigo-500/20">
<span className="text-[12px] text-indigo-300 font-medium">{selected.size} selected</span>
<div className="h-3 w-px bg-indigo-500/30" />
<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 className="flex-1 overflow-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]">
<th className="w-10 px-3 py-2.5">
<input
type="checkbox"
checked={allSelected}
ref={el => { if (el) el.indeterminate = someSelected; }}
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.key} className="px-3 py-2.5 whitespace-nowrap">
<button
onClick={() => handleSort(col.key)}
className={cn(
'flex items-center gap-1 text-[11px] font-semibold tracking-wide uppercase transition-colors',
sort.key === col.key ? 'text-zinc-300' : 'text-zinc-600 hover:text-zinc-400'
)}
>
{col.label}
{sort.key === col.key ? (
sort.dir === 'asc'
? <ChevronUp className="w-3 h-3 text-indigo-400" />
: <ChevronDown className="w-3 h-3 text-indigo-400" />
) : (
<ArrowUpDown className="w-3 h-3 opacity-30" />
)}
</button>
</th>
))}
</tr>
</thead>
<tbody>
{sorted.map(row => {
const st = STATUS_TOKENS[row.status];
const pr = PRIORITY_TOKENS[row.priority];
const isSelected = selected.has(row.id);
return (
<tr
key={row.id}
className={cn(
'border-b border-white/[0.04] last:border-0 transition-colors',
isSelected ? 'bg-indigo-500/[0.06]' : 'hover:bg-white/[0.02]'
)}
>
<td className="w-10 px-3 py-2.5">
<input
type="checkbox"
checked={isSelected}
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>
<td className="px-3 py-2.5">
<span className="text-[12px] font-mono text-zinc-600">{row.id}</span>
</td>
<td className="px-3 py-2.5 max-w-[240px]">
<span className="text-[13px] text-zinc-300 truncate block">{row.title}</span>
</td>
<td className="px-3 py-2.5 whitespace-nowrap">
<span className={cn('inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium', st.bg, st.border, st.text)}>
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0', st.dot)} />
{st.label}
</span>
</td>
<td className="px-3 py-2.5 whitespace-nowrap">
<span className={cn('rounded-full border px-2 py-0.5 text-[11px] font-medium', pr.bg, pr.border, pr.text)}>
{pr.label}
</span>
</td>
<td className="px-3 py-2.5">
<span className="text-[13px] text-zinc-400">{row.assignee}</span>
</td>
<td className="px-3 py-2.5 whitespace-nowrap">
<span className="text-[12px] text-zinc-600" style={{ fontVariantNumeric: 'tabular-nums' }}>
{row.date}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</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