Inline Data Table

A fully interactive data table with sortable columns, selectable rows, bulk actions, badge cells, and loading skeletons. Inspired by Notion and Linear's clean table aesthetic.

Data TablesInspired by Notion + Linear
Live PreviewInteractive
8 members
Joined
SCSarah Chen
[email protected]AdminActive3 months ago
MJMarcus Johnson
[email protected]MemberActive1 month ago
PPPriya Patel
[email protected]MemberActive2 weeks ago
ARAlex Rivera
[email protected]ViewerInactive5 months ago
JKJordan Kim
[email protected]MemberActive1 week ago
TWTaylor Wong
[email protected]AdminActive6 months ago
SOSam Okafor
[email protected]ViewerPending2 days ago
RPRiley Park
[email protected]MemberActive3 weeks ago
InlineDataTable.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useMemo } from 'react';
import { ChevronUp, ChevronDown, MoreHorizontal, Trash2, Download, X } from 'lucide-react';

export type Column = {
  id: string;
  label: string;
  type: 'text' | 'badge' | 'person' | 'date' | 'number' | 'actions';
  sortable?: boolean;
  width?: number;
};

export type Row = {
  id: string;
  cells: Record<string, any>;
};

type Props = {
  columns: Column[];
  rows: Row[];
  selectable?: boolean;
  sortable?: boolean;
  loading?: boolean;
  onRowClick?: (row: Row) => void;
};

const BADGE_COLORS: Record<string, string> = {
  Admin: 'bg-purple-500/15 text-purple-300 border-purple-500/20',
  Member: 'bg-blue-500/15 text-blue-300 border-blue-500/20',
  Viewer: 'bg-zinc-800 text-zinc-400 border-zinc-700/50',
  Active: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/20',
  Inactive: 'bg-zinc-800 text-zinc-500 border-zinc-700/50',
  Pending: 'bg-yellow-500/15 text-yellow-300 border-yellow-500/20',
};

function Badge({ value }: { value: string }) {
  const cls = BADGE_COLORS[value] ?? 'bg-zinc-800 text-zinc-400 border-zinc-700/50';
  return <span className={"text-[11px] px-2 py-0.5 rounded-full border font-medium " + cls}>{value}</span>;
}

function PersonCell({ value }: { value: { name: string; initials: string; color: string } }) {
  return (
    <div className="flex items-center gap-2">
      <span className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shrink-0" style={{ backgroundColor: value.color }}>{value.initials}</span>
      <span className="text-[13px] text-zinc-300">{value.name}</span>
    </div>
  );
}

type SortState = { column: string; direction: 'asc' | 'desc' } | null;

export function InlineDataTable({ columns, rows, selectable, sortable, loading, onRowClick }: Props) {
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [sort, setSort] = useState<SortState>(null);

  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 toggleAll = () => { if (selected.size === rows.length) setSelected(new Set()); else setSelected(new Set(rows.map((r) => r.id))); };
  const handleSort = (colId: string) => { if (!sortable) return; setSort((prev) => { if (prev?.column !== colId) return { column: colId, direction: 'asc' }; if (prev.direction === 'asc') return { column: colId, direction: 'desc' }; return null; }); };

  const sortedRows = useMemo(() => {
    if (!sort) return rows;
    return [...rows].sort((a, b) => {
      const cmp = String(a.cells[sort.column] ?? '').localeCompare(String(b.cells[sort.column] ?? ''));
      return sort.direction === 'asc' ? cmp : -cmp;
    });
  }, [rows, sort]);

  const allSelected = rows.length > 0 && selected.size === rows.length;

  return (
    <div className="relative flex flex-col h-full">
      <div className="flex-1 overflow-x-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]">
              {selectable && (
                <th className="w-10 px-3 py-2.5">
                  <input type="checkbox" checked={allSelected} 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.id} className="px-3 py-2.5 text-[11px] font-semibold text-zinc-500 tracking-wide uppercase whitespace-nowrap" style={col.width ? { width: col.width } : undefined}>
                  {sortable && col.sortable !== false && col.type !== 'actions' ? (
                    <button onClick={() => handleSort(col.id)} className="flex items-center gap-1 hover:text-zinc-300 transition-colors">
                      {col.label}
                      <span className="flex flex-col -space-y-0.5 ml-0.5">
                        <ChevronUp className={"w-2.5 h-2.5 " + (sort?.column === col.id && sort.direction === 'asc' ? 'text-indigo-400' : 'text-zinc-700')} />
                        <ChevronDown className={"w-2.5 h-2.5 " + (sort?.column === col.id && sort.direction === 'desc' ? 'text-indigo-400' : 'text-zinc-700')} />
                      </span>
                    </button>
                  ) : col.label}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {loading ? (
              [0, 1, 2, 3].map((i) => (
                <tr key={i} className="border-b border-white/[0.04]">
                  {selectable && <td className="w-10 px-3 py-3"><div className="w-3.5 h-3.5 rounded bg-zinc-800 animate-pulse" /></td>}
                  {columns.map((col) => <td key={col.id} className="px-3 py-3"><div className="h-3 rounded bg-zinc-800 animate-pulse" style={{ width: '60%' }} /></td>)}
                </tr>
              ))
            ) : sortedRows.length === 0 ? (
              <tr><td colSpan={columns.length + (selectable ? 1 : 0)} className="px-3 py-12 text-center text-sm text-zinc-600">No data</td></tr>
            ) : sortedRows.map((row) => (
              <tr key={row.id} className={"group border-b border-white/[0.04] last:border-0 transition-colors " + (onRowClick ? 'cursor-pointer ' : '') + (selected.has(row.id) ? 'bg-indigo-500/[0.06]' : 'hover:bg-white/[0.02]')} onClick={() => onRowClick?.(row)}>
                {selectable && (
                  <td className="w-10 px-3 py-2.5" onClick={(e) => { e.stopPropagation(); toggleRow(row.id); }}>
                    <input type="checkbox" checked={selected.has(row.id)} 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>
                )}
                {columns.map((col) => (
                  <td key={col.id} className="px-3 py-2.5">
                    {col.type === 'badge' ? <Badge value={row.cells[col.id]} />
                      : col.type === 'person' ? <PersonCell value={row.cells[col.id]} />
                      : col.type === 'date' ? <span className="text-[13px] text-zinc-500">{row.cells[col.id]}</span>
                      : col.type === 'number' ? <span className="text-[13px] text-zinc-300 font-mono">{row.cells[col.id]}</span>
                      : col.type === 'actions' ? (
                        <button className="p-1 rounded hover:bg-white/[0.08] text-zinc-600 hover:text-zinc-300 transition-colors opacity-0 group-hover:opacity-100" onClick={(e) => e.stopPropagation()}>
                          <MoreHorizontal className="w-4 h-4" />
                        </button>
                      ) : <span className="text-[13px] text-zinc-300">{row.cells[col.id]}</span>}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {selected.size > 0 && (
        <div className="shrink-0 flex items-center gap-3 px-4 py-2.5 bg-zinc-900/90 border-t border-white/[0.08]">
          <span className="text-[12px] text-zinc-400">{selected.size} {selected.size === 1 ? 'row' : 'rows'} selected</span>
          <div className="h-3 w-px bg-zinc-700" />
          <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>
  );
}

Unlock to copy

Free access to all patterns