A data table where clicking a cell turns it into an editable input or select dropdown depending on column type. Enter commits, Esc cancels, and blur commits text edits. Status-style select cells use a floating dropdown that flips upward for rows near the bottom of the table.
| Task | Owner | Status |
|---|---|---|
| Write technical spec | Sarah Chen | Done |
| Design API surface | Marcus Kim | Done |
| Implement backend handler | Lena Park | In Progress |
| Add unit tests | Alex Liu | Todo |
| Review PR feedback | Priya Mehta | Blocked |
| Ship to staging | Sarah Chen | Todo |
Click a cell to edit · Enter commits · Esc cancels · Status cells flip upward near the bottom
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useEffect } from 'react';
import { ChevronDown } from 'lucide-react';
function cn(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(' ');
}
export type ColumnType = 'text' | 'select';
export type ColumnDef = {
key: string;
label: string;
type: ColumnType;
options?: string[];
};
export type RowData = Record<string, string>;
type Props = {
columns: ColumnDef[];
initialRows: RowData[];
};
const STATUS_COLOR: Record<string, string> = {
Todo: 'text-zinc-400',
'In Progress': 'text-amber-400',
Done: 'text-emerald-400',
Blocked: 'text-red-400',
};
export function EditableTable({ columns, initialRows }: Props) {
const [rows, setRows] = useState<RowData[]>(initialRows);
const [editing, setEditing] = useState<{ row: number; key: string } | null>(null);
const [draft, setDraft] = useState('');
const startEdit = (rowIndex: number, col: ColumnDef) => {
setEditing({ row: rowIndex, key: col.key });
setDraft(rows[rowIndex][col.key] ?? '');
};
const commit = () => {
if (!editing) return;
setRows((prev) => prev.map((r, i) => (i === editing.row ? { ...r, [editing.key]: draft } : r)));
setEditing(null);
};
const cancel = () => setEditing(null);
const selectValue = (rowIndex: number, key: string, value: string) => {
setRows((prev) => prev.map((r, i) => (i === rowIndex ? { ...r, [key]: value } : r)));
setEditing(null);
};
return (
<div className="w-full rounded-xl border border-[#1a1a1a] bg-[#0a0a0a] overflow-hidden">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/[0.08] bg-[#111]">
{columns.map((col) => (
<th
key={col.key}
className="px-3 py-2.5 text-[11px] font-semibold text-zinc-500 uppercase tracking-wide whitespace-nowrap"
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const openUpward = rows.length > 1 && rowIndex >= Math.ceil(rows.length / 2);
return (
<tr key={rowIndex} className="border-b border-white/[0.04] last:border-0">
{columns.map((col, colIndex) => {
const isEditing = editing?.row === rowIndex && editing.key === col.key;
const alignRight = colIndex === columns.length - 1;
return (
<td
key={col.key}
onClick={() => !isEditing && startEdit(rowIndex, col)}
className="px-3 py-2 cursor-text"
>
{isEditing && col.type === 'text' ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') cancel();
}}
onBlur={commit}
className="w-full bg-[#151515] border border-indigo-500/40 rounded-md px-2 py-1 text-[13px] text-white focus:outline-none"
/>
) : isEditing && col.type === 'select' ? (
<SelectCell
options={col.options ?? []}
value={row[col.key]}
openUpward={openUpward}
alignRight={alignRight}
onSelect={(v) => selectValue(rowIndex, col.key, v)}
onClose={cancel}
/>
) : col.type === 'select' ? (
<span
className={cn(
'text-[13px] font-medium',
STATUS_COLOR[row[col.key]] ?? 'text-zinc-300'
)}
>
{row[col.key] || '—'}
</span>
) : (
<span className="text-[13px] text-zinc-300">{row[col.key] || '—'}</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
type SelectCellProps = {
options: string[];
value: string;
openUpward: boolean;
alignRight: boolean;
onSelect: (v: string) => void;
onClose: () => void;
};
function SelectCell({ options, value, openUpward, alignRight, onSelect, onClose }: SelectCellProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div ref={ref} className="relative inline-block">
<span
className={cn(
'flex items-center gap-1.5 text-[13px] font-medium',
STATUS_COLOR[value] ?? 'text-zinc-300'
)}
>
{value || '—'}
<ChevronDown className="w-3 h-3 opacity-60" />
</span>
<div
className={cn(
'absolute z-50 min-w-[140px] max-h-[180px] overflow-y-auto bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-2xl py-1',
openUpward ? 'bottom-full mb-1' : 'top-full mt-1',
alignRight ? 'right-0' : 'left-0'
)}
>
{options.map((opt) => (
<button
key={opt}
onClick={() => onSelect(opt)}
className={cn(
'w-full text-left px-3 py-1.5 text-xs transition-colors',
opt === value
? 'bg-indigo-500/15 text-indigo-200'
: 'text-zinc-200 hover:bg-white/[0.06] hover:text-white'
)}
>
{opt}
</button>
))}
</div>
</div>
);
}
// Usage:
// const COLUMNS = [
// { key: 'task', label: 'Task', type: 'text' },
// { key: 'owner', label: 'Owner', type: 'text' },
// { key: 'status', label: 'Status', type: 'select', options: ['Todo', 'In Progress', 'Done', 'Blocked'] },
// ];
// const ROWS = [{ task: 'Write spec', owner: 'Sarah Chen', status: 'Done' }];
// <EditableTable columns={COLUMNS} initialRows={ROWS} />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