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
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Status Badge System
Linear
Segmented Control
Notion