Sortable / Selectable Table

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.

Data TablesInspired by Linear
Live PreviewInteractive
Linear · Issue table
MOD-001Fix authentication flow on mobile devicesIn ProgressHighSarah Chen2024-01-15
MOD-002Implement dark mode for settings panelTodoMediumMarcus Johnson2024-01-14
MOD-003Database query optimization for reports pageIn ReviewUrgentPriya Patel2024-01-13
MOD-004Add CSV export to analytics pageDoneLowAlex Rivera2024-01-12
MOD-005Update rate limiting middleware for API v2BlockedHighSarah Chen2024-01-11
MOD-006Redesign onboarding flow for new workspace membersTodoMediumMarcus Johnson2024-01-10
MOD-007Fix memory leak in WebSocket event handlerIn ProgressUrgentPriya Patel2024-01-09

Click column headers to sort asc/desc · Check rows to select · Bulk action bar appears when rows are selected

SortableTable.tsx
// 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