Editable Cell

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.

Data TablesInspired by Notion
Live PreviewInteractive
Notion · Tracker
TaskOwnerStatus
Write technical specSarah ChenDone
Design API surfaceMarcus KimDone
Implement backend handlerLena ParkIn Progress
Add unit testsAlex LiuTodo
Review PR feedbackPriya MehtaBlocked
Ship to stagingSarah ChenTodo

Click a cell to edit · Enter commits · Esc cancels · Status cells flip upward near the bottom

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