Record Table New
A spreadsheet-style record grid with sticky columns, typed cells, inline editing, keyboard navigation and bulk actions.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/crm-record-table.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/crm/RecordTable.tsx
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Mail, Plus, Star, Trash2, Undo2, X } from 'lucide-react';
import { cn } from '@/lib/cn';
export type Stage = 'Lead' | 'Qualified' | 'Customer' | 'Churned';
export type PersonRow = {
id: string;
name: string;
email: string;
phone: string;
company: string;
stage: Stage;
arr: number;
lastContact: string;
rating: number;
};
type Key = keyof Omit<PersonRow, 'id'>;
type Col = { key: Key; label: string; width: string; edit?: 'text' | 'select' | 'number' | 'date' };
const STAGES: Stage[] = ['Lead', 'Qualified', 'Customer', 'Churned'];
const STAGE_STYLE: Record<Stage, string> = {
Lead: 'bg-sky-500/10 text-sky-300 ring-sky-400/25',
Qualified: 'bg-violet-500/10 text-violet-300 ring-violet-400/25',
Customer: 'bg-emerald-500/10 text-emerald-300 ring-emerald-400/25',
Churned: 'bg-rose-500/10 text-rose-300 ring-rose-400/25',
};
const COLS: Col[] = [
{ key: 'name', label: 'Name', width: 'min-w-[210px]', edit: 'text' },
{ key: 'email', label: 'Email', width: 'min-w-[210px]', edit: 'text' },
{ key: 'phone', label: 'Phone', width: 'min-w-[150px]', edit: 'text' },
{ key: 'company', label: 'Company', width: 'min-w-[160px]', edit: 'text' },
{ key: 'stage', label: 'Stage', width: 'min-w-[130px]', edit: 'select' },
{ key: 'arr', label: 'ARR', width: 'min-w-[110px]', edit: 'number' },
{ key: 'lastContact', label: 'Last contact', width: 'min-w-[130px]', edit: 'date' },
{ key: 'rating', label: 'Rating', width: 'min-w-[120px]' },
];
const HUES = ['bg-sky-500/20 text-sky-200', 'bg-violet-500/20 text-violet-200', 'bg-emerald-500/20 text-emerald-200', 'bg-amber-500/20 text-amber-200', 'bg-rose-500/20 text-rose-200', 'bg-teal-500/20 text-teal-200'];
export const hue = (s: string) => HUES[[...s].reduce((a, c) => a + c.charCodeAt(0), 0) % HUES.length];
export const initials = (s: string) =>
s
.split(/\s+/)
.filter(Boolean)
.map((w) => w[0])
.slice(0, 2)
.join('')
.toUpperCase() || '?';
const money = (n: number) => '$' + n.toLocaleString('en-US');
const day = (iso: string) => (iso ? new Date(iso + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—');
export type RecordTableProps = {
initialRows: PersonRow[];
/** Row ids selected on mount. */
initialSelected?: string[];
onChange?: (rows: PersonRow[]) => void;
className?: string;
};
export function RecordTable({ initialRows, initialSelected = [], onChange, className }: RecordTableProps) {
const [rows, setRows] = useState(initialRows);
const [sort, setSort] = useState<{ key: Key; dir: 'asc' | 'desc' } | null>(null);
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialSelected));
const [active, setActive] = useState({ r: 0, c: 0 });
const [focused, setFocused] = useState(false);
const [editing, setEditing] = useState<{ id: string; key: Key } | null>(null);
const [draft, setDraft] = useState('');
const [undo, setUndo] = useState<{ rows: PersonRow[]; n: number } | null>(null);
const wrap = useRef<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const cells = useRef(new Map<string, HTMLTableCellElement>());
const byKeyboard = useRef(false);
const headAll = useRef<HTMLInputElement>(null);
const editingRef = useRef<{ id: string; key: Key } | null>(null);
useEffect(() => onChange?.(rows), [rows]); // eslint-disable-line react-hooks/exhaustive-deps
const view = useMemo(() => {
if (!sort) return rows;
const dir = sort.dir === 'asc' ? 1 : -1;
return [...rows].sort((a, b) => {
const x = a[sort.key];
const y = b[sort.key];
return (typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y))) * dir;
});
}, [rows, sort]);
const allOn = view.length > 0 && view.every((r) => selected.has(r.id));
const someOn = view.some((r) => selected.has(r.id));
useEffect(() => {
if (headAll.current) headAll.current.indeterminate = someOn && !allOn;
}, [someOn, allOn]);
useEffect(() => {
if (!undo) return;
const id = window.setTimeout(() => setUndo(null), 5000);
return () => clearTimeout(id);
}, [undo]);
// Keep the active cell visible inside the scroller without scrolling the page.
useEffect(() => {
if (!byKeyboard.current) return;
byKeyboard.current = false;
const sc = scroller.current;
const row = view[active.r];
const cell = row && cells.current.get(`${row.id}:${active.c}`);
if (!sc || !cell) return;
const s = sc.getBoundingClientRect();
const c = cell.getBoundingClientRect();
const stickyW = 210;
const headH = 40;
if (active.c > 0 && c.left < s.left + stickyW) sc.scrollLeft -= s.left + stickyW - c.left;
if (c.right > s.right) sc.scrollLeft += c.right - s.right;
if (c.top < s.top + headH) sc.scrollTop -= s.top + headH - c.top;
if (c.bottom > s.bottom) sc.scrollTop += c.bottom - s.bottom;
}, [active, view]);
const toggle = (id: string) =>
setSelected((s) => {
const n = new Set(s);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const start = (id: string, key: Key) => {
const col = COLS.find((c) => c.key === key);
const row = rows.find((r) => r.id === id);
if (!col?.edit || !row) return;
setDraft(String(row[key]));
editingRef.current = { id, key };
setEditing({ id, key });
};
const commitValue = (value: string) => {
const current = editingRef.current;
if (!current) return;
editingRef.current = null;
const { id, key } = current;
setRows((rs) =>
rs.map((r) => {
if (r.id !== id) return r;
if (key === 'arr') return { ...r, arr: Number(value.replace(/[^\d.]/g, '')) || 0 };
if (key === 'name') return { ...r, name: value.trim() || 'Untitled' };
return { ...r, [key]: value } as PersonRow;
}),
);
setEditing(null);
requestAnimationFrame(() => wrap.current?.focus({ preventScroll: true }));
};
const cancel = () => {
editingRef.current = null;
setEditing(null);
requestAnimationFrame(() => wrap.current?.focus({ preventScroll: true }));
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (editing || e.target !== wrap.current) return;
const maxR = view.length - 1;
const maxC = COLS.length - 1;
const move = (dr: number, dc: number) => {
e.preventDefault();
byKeyboard.current = true;
setActive((a) => ({ r: Math.max(0, Math.min(maxR, a.r + dr)), c: Math.max(0, Math.min(maxC, a.c + dc)) }));
};
if (e.key === 'ArrowDown') move(1, 0);
else if (e.key === 'ArrowUp') move(-1, 0);
else if (e.key === 'ArrowRight') move(0, 1);
else if (e.key === 'ArrowLeft') move(0, -1);
else if (e.key === 'Enter') {
e.preventDefault();
const row = view[active.r];
if (row) start(row.id, COLS[active.c].key);
} else if (e.key === ' ') {
e.preventDefault();
const row = view[active.r];
if (row) toggle(row.id);
} else if (e.key === 'Escape') setSelected(new Set());
};
const sortBy = (key: Key) =>
setSort((s) => (!s || s.key !== key ? { key, dir: 'asc' } : s.dir === 'asc' ? { key, dir: 'desc' } : null));
const addRecord = () => {
const id = `new-${Date.now()}`;
const today = new Date().toISOString().slice(0, 10);
setSort(null);
setRows((rs) => [...rs, { id, name: '', email: '', phone: '', company: '', stage: 'Lead', arr: 0, lastContact: today, rating: 0 }]);
setActive({ r: rows.length, c: 0 });
setDraft('');
editingRef.current = { id, key: 'name' };
setEditing({ id, key: 'name' });
byKeyboard.current = true;
};
const removeSelected = () => {
const gone = rows.filter((r) => selected.has(r.id));
setUndo({ rows, n: gone.length });
setRows((rs) => rs.filter((r) => !selected.has(r.id)));
setSelected(new Set());
setActive({ r: 0, c: 0 });
};
const total = rows.reduce((s, r) => s + r.arr, 0);
const renderValue = (row: PersonRow, col: Col) => {
switch (col.key) {
case 'name':
return (
<span className="flex min-w-0 items-center gap-2.5">
<span className={cn('grid h-6 w-6 shrink-0 place-items-center rounded-full text-[10px] font-semibold', hue(row.name || '?'))}>{initials(row.name)}</span>
<span className={cn('truncate font-medium', row.name ? 'text-ink-50' : 'text-ink-500')}>{row.name || 'Untitled'}</span>
</span>
);
case 'email':
return row.email ? <span className="truncate text-brand-300/90">{row.email}</span> : <span className="text-ink-600">Add email</span>;
case 'phone':
return <span className="font-mono text-[12px] tabular-nums text-ink-300">{row.phone || '—'}</span>;
case 'company':
return row.company ? (
<span className="inline-flex max-w-full items-center gap-1.5 rounded-md bg-white/[0.04] py-0.5 pl-0.5 pr-2 ring-1 ring-white/[0.06]">
<span className={cn('grid h-[18px] w-[18px] shrink-0 place-items-center rounded-[5px] text-[9px] font-bold', hue(row.company))}>{row.company[0]}</span>
<span className="truncate text-ink-200">{row.company}</span>
</span>
) : (
<span className="text-ink-600">—</span>
);
case 'stage':
return <span className={cn('inline-flex rounded-full px-2 py-0.5 text-[11.5px] font-medium ring-1', STAGE_STYLE[row.stage])}>{row.stage}</span>;
case 'arr':
return <span className="font-mono text-[12.5px] tabular-nums text-ink-100">{row.arr ? money(row.arr) : '—'}</span>;
case 'lastContact':
return <span className="text-ink-300">{day(row.lastContact)}</span>;
case 'rating':
return (
<span className="flex items-center gap-0.5" role="radiogroup" aria-label={`Rating for ${row.name || 'record'}`}>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
role="radio"
aria-checked={row.rating === n}
aria-label={`${n} star${n > 1 ? 's' : ''}`}
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
setRows((rs) => rs.map((r) => (r.id === row.id ? { ...r, rating: r.rating === n ? 0 : n } : r)));
}}
className="rounded p-px transition-transform hover:scale-110"
>
<Star className={cn('h-3.5 w-3.5', n <= row.rating ? 'fill-amber-300 text-amber-300' : 'text-ink-700')} />
</button>
))}
</span>
);
}
};
const renderEditor = (row: PersonRow, col: Col) => {
const base = 'h-7 w-full min-w-0 rounded-md bg-ink-950 px-2 text-[13px] text-ink-50 outline-none ring-1 ring-brand-400/60';
const keys = (e: KeyboardEvent<HTMLInputElement | HTMLSelectElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commitValue(draft);
} else if (e.key === 'Escape') {
e.preventDefault();
cancel();
}
e.stopPropagation();
};
if (col.edit === 'select')
return (
<select autoFocus value={draft} onChange={(e) => commitValue(e.target.value)} onBlur={cancel} onKeyDown={keys} className={cn(base, '[color-scheme:dark]')} aria-label={`Edit ${col.label}`}>
{STAGES.map((s) => (
<option key={s}>{s}</option>
))}
</select>
);
return (
<input
autoFocus
type={col.edit === 'date' ? 'date' : 'text'}
inputMode={col.edit === 'number' ? 'numeric' : undefined}
value={draft}
placeholder={col.key === 'name' ? 'Full name' : col.label}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => commitValue(draft)}
onKeyDown={keys}
className={cn(base, col.edit === 'date' && '[color-scheme:dark]')}
aria-label={`Edit ${col.label} for ${row.name || 'new record'}`}
/>
);
};
return (
<div className={cn('relative w-full overflow-hidden rounded-2xl bg-ink-900 shadow-card', className)}>
<div
ref={wrap}
tabIndex={0}
role="grid"
aria-label="People"
aria-rowcount={view.length + 1}
onKeyDown={onKey}
onFocus={() => setFocused(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setFocused(false)}
className="outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40 focus-visible:ring-inset"
>
<div ref={scroller} className="relative max-h-[340px] overflow-auto [scrollbar-width:thin]">
<table className="w-max min-w-full border-separate border-spacing-0 text-left text-[13px]">
<thead>
<tr>
{COLS.map((col, ci) => {
const on = sort?.key === col.key;
const Icon = !on ? ArrowUpDown : sort?.dir === 'asc' ? ArrowUp : ArrowDown;
return (
<th
key={col.key}
scope="col"
aria-sort={on ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
className={cn('sticky top-0 z-20 h-10 border-b border-white/[0.07] bg-ink-900 px-3 font-medium text-ink-400', col.width, ci === 0 && 'left-0 z-30 border-r border-r-white/[0.05]')}
>
<span className="flex items-center gap-2">
{ci === 0 && (
<input
ref={headAll}
type="checkbox"
checked={allOn}
onChange={() => setSelected(allOn ? new Set() : new Set(view.map((r) => r.id)))}
aria-label="Select all rows"
className="h-3.5 w-3.5 cursor-pointer accent-sky-400"
/>
)}
<button onClick={() => sortBy(col.key)} className="focus-ring group/s -mx-1 flex items-center gap-1.5 rounded px-1 py-0.5 text-[12px] uppercase tracking-wider transition-colors hover:text-ink-100">
{col.label}
<Icon className={cn('h-3 w-3 transition-opacity', on ? 'text-brand-300' : 'opacity-0 group-hover/s:opacity-60')} />
</button>
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{view.map((row, ri) => {
const on = selected.has(row.id);
return (
<tr key={row.id} className="group" aria-selected={on}>
{COLS.map((col, ci) => {
const isActive = active.r === ri && active.c === ci;
const isEditing = editing?.id === row.id && editing.key === col.key;
return (
<td
key={col.key}
ref={(el) => {
if (el) cells.current.set(`${row.id}:${ci}`, el);
else cells.current.delete(`${row.id}:${ci}`);
}}
onClick={() => setActive({ r: ri, c: ci })}
onDoubleClick={() => start(row.id, col.key)}
className={cn(
'relative h-11 max-w-[260px] border-b border-white/[0.05] px-3 transition-colors',
on ? 'bg-[#0c1821] group-hover:bg-[#0f1e29]' : 'bg-ink-900 group-hover:bg-ink-850',
ci === 0 && 'sticky left-0 z-10 border-r border-r-white/[0.05]',
col.edit && 'cursor-text',
isActive && (focused || isEditing) && 'outline outline-1 -outline-offset-1 outline-brand-400/70',
)}
>
<span className="flex min-w-0 items-center gap-2">
{ci === 0 && (
<input
type="checkbox"
checked={on}
onChange={() => toggle(row.id)}
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
aria-label={`Select ${row.name || 'record'}`}
className={cn('h-3.5 w-3.5 shrink-0 cursor-pointer accent-sky-400 transition-opacity', !on && 'opacity-40 group-hover:opacity-100')}
/>
)}
<span className="flex min-w-0 flex-1 items-center">{isEditing ? renderEditor(row, col) : renderValue(row, col)}</span>
</span>
</td>
);
})}
</tr>
);
})}
<tr>
<td colSpan={COLS.length} className="sticky left-0 h-10 bg-ink-900 px-3">
<button onClick={addRecord} className="focus-ring flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[12.5px] text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100">
<Plus className="h-3.5 w-3.5" /> Add record
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-white/[0.06] px-4 py-2.5 text-[12px] text-ink-500">
<span>
<span className="tabular-nums text-ink-300">{rows.length}</span> records
<span className="hidden sm:inline"> · ↑↓←→ move · Enter edit · Space select</span>
</span>
<span>
ARR <span className="font-mono tabular-nums text-ink-100">{money(total)}</span>
</span>
</div>
{selected.size > 0 && (
<div className="pointer-events-none absolute inset-x-0 bottom-12 z-40 flex justify-center px-3">
<div className="pointer-events-auto flex items-center gap-0.5 whitespace-nowrap rounded-xl bg-ink-800 p-1 text-[12.5px] shadow-pop animate-fade-up">
<span className="px-2.5 font-medium text-ink-100">
<span className="tabular-nums text-brand-300">{selected.size}</span> selected
</span>
<span className="mx-0.5 h-4 w-px bg-white/10" />
<button className="focus-ring flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-ink-300 transition-colors hover:bg-white/5 hover:text-ink-50">
<Mail className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Email</span>
</button>
<button className="focus-ring flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-ink-300 transition-colors hover:bg-white/5 hover:text-ink-50">
<Download className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Export</span>
</button>
<button onClick={removeSelected} className="focus-ring flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-rose-300 transition-colors hover:bg-rose-500/10">
<Trash2 className="h-3.5 w-3.5" /> <span className="hidden sm:inline">Delete</span>
</button>
<button onClick={() => setSelected(new Set())} aria-label="Clear selection" className="focus-ring grid h-7 w-7 place-items-center rounded-lg text-ink-500 transition-colors hover:bg-white/5 hover:text-ink-100">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{undo && selected.size === 0 && (
<div className="pointer-events-none absolute inset-x-0 bottom-12 z-40 flex justify-center px-3">
<div role="status" className="pointer-events-auto flex items-center gap-2 whitespace-nowrap rounded-xl bg-ink-800 py-1.5 pl-3 pr-1.5 text-[12.5px] text-ink-200 shadow-pop animate-fade-up">
Deleted {undo.n} record{undo.n > 1 ? 's' : ''}
<button
onClick={() => {
setRows(undo.rows);
setUndo(null);
}}
className="focus-ring flex items-center gap-1 rounded-lg px-2 py-1 text-brand-300 transition-colors hover:bg-white/5"
>
<Undo2 className="h-3.5 w-3.5" /> Undo
</button>
</div>
</div>
)}
</div>
);
}
export const DEMO_PEOPLE: PersonRow[] = [
{ id: 'p1', name: 'Amara Okafor', email: 'amara@lumenpay.io', phone: '+1 415 555 0142', company: 'Lumenpay', stage: 'Customer', arr: 84000, lastContact: '2026-10-27', rating: 5 },
{ id: 'p2', name: 'Jonas Weber', email: 'jonas@kestrel.dev', phone: '+49 30 5550 1180', company: 'Kestrel Labs', stage: 'Qualified', arr: 42000, lastContact: '2026-10-24', rating: 4 },
{ id: 'p3', name: 'Priya Raman', email: 'priya@northbeam.co', phone: '+44 20 7946 0921', company: 'Northbeam', stage: 'Lead', arr: 18000, lastContact: '2026-10-21', rating: 3 },
{ id: 'p4', name: 'Mateo Silva', email: 'mateo@harborline.com', phone: '+55 11 5550 3307', company: 'Harborline', stage: 'Qualified', arr: 56000, lastContact: '2026-10-19', rating: 4 },
{ id: 'p5', name: 'Hana Kobayashi', email: 'hana@sorabase.jp', phone: '+81 3 5550 7781', company: 'Sorabase', stage: 'Customer', arr: 120000, lastContact: '2026-10-28', rating: 5 },
{ id: 'p6', name: 'Elliot Grant', email: 'elliot@quillstack.io', phone: '+1 646 555 0193', company: 'Quillstack', stage: 'Churned', arr: 9000, lastContact: '2026-09-02', rating: 2 },
{ id: 'p7', name: 'Sofia Marin', email: 'sofia@verdantos.com', phone: '+34 91 555 0144', company: 'Verdant OS', stage: 'Lead', arr: 24000, lastContact: '2026-10-15', rating: 3 },
{ id: 'p8', name: 'Kwame Mensah', email: 'kwame@tidewater.ai', phone: '+233 30 555 0175', company: 'Tidewater AI', stage: 'Qualified', arr: 67000, lastContact: '2026-10-26', rating: 4 },
{ id: 'p9', name: 'Lena Fischer', email: 'lena@brightmill.de', phone: '+49 89 5550 2240', company: 'Brightmill', stage: 'Customer', arr: 38000, lastContact: '2026-10-12', rating: 4 },
{ id: 'p10', name: 'Ravi Menon', email: 'ravi@atlasfreight.in', phone: '+91 80 5550 6612', company: 'Atlas Freight', stage: 'Lead', arr: 15000, lastContact: '2026-10-08', rating: 2 },
];
export function RecordTableDemo() {
return (
<div className="w-full max-w-[920px]">
<RecordTable initialRows={DEMO_PEOPLE} initialSelected={['p2', 'p4']} />
</div>
);
}