niceui

Data Table New

A full data table with search, column visibility, sorting, row selection, row actions, status badges and pagination.

ReactTailwind CSSTypeScriptApplicationMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/application-data-table.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/app/DataTablePro.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Columns3, Copy, MoreHorizontal, Pencil, Search, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';

type Status = 'Active' | 'Trial' | 'Past due' | 'Churned';
export type Row = { id: number; name: string; email: string; plan: string; status: Status; mrr: number; seats: number; joined: string };

const NAMES = ['Lumenpay', 'Kestrel Labs', 'Northbeam', 'Harborline', 'Sorabase', 'Quillstack', 'Verdant OS', 'Tidewater AI', 'Atlas Freight', 'Brightmill', 'Pellucid', 'Orchard Bank', 'Fernway', 'Copperline', 'Halcyon', 'Stratus', 'Paperplane', 'Arbor Finance', 'Quill & Co', 'Tidal Robotics', 'Meridian', 'Kinetik', 'Sundial', 'Field & Frame'];
const PLANS = ['Starter', 'Team', 'Business', 'Enterprise'];
const STATUS: Status[] = ['Active', 'Active', 'Trial', 'Active', 'Past due', 'Active', 'Churned', 'Trial'];
export const DEMO_ROWS: Row[] = NAMES.map((name, i) => ({
  id: i + 1,
  name,
  email: `billing@${name.toLowerCase().replace(/[^a-z]+/g, '')}.com`,
  plan: PLANS[(i * 7) % 4],
  status: STATUS[(i * 5) % 8],
  mrr: [49, 240, 960, 4800][(i * 7) % 4] * (1 + (i % 3)),
  seats: 3 + ((i * 11) % 60),
  joined: `2026-0${1 + (i % 9)}-${String(1 + ((i * 3) % 28)).padStart(2, '0')}`,
}));

const COLS = [
  { key: 'email', label: 'Email' },
  { key: 'plan', label: 'Plan' },
  { key: 'status', label: 'Status' },
  { key: 'mrr', label: 'MRR' },
  { key: 'seats', label: 'Seats' },
  { key: 'joined', label: 'Joined' },
] as const;
type ColKey = 'name' | (typeof COLS)[number]['key'];

const BADGE: Record<Status, string> = {
  Active: 'bg-emerald-500/10 text-emerald-300',
  Trial: 'bg-brand-500/10 text-brand-300',
  'Past due': 'bg-amber-500/10 text-amber-300',
  Churned: 'bg-white/5 text-ink-400',
};

export function DataTablePro({ rows: initial = DEMO_ROWS, loadingFirst = true }: { rows?: Row[]; loadingFirst?: boolean }) {
  const [rows, setRows] = useState(initial);
  const [loading, setLoading] = useState(loadingFirst);
  const [q, setQ] = useState('');
  const [sort, setSort] = useState<{ key: ColKey; dir: 1 | -1 }>({ key: 'mrr', dir: -1 });
  const [hidden, setHidden] = useState<Set<ColKey>>(new Set(['joined']));
  const [sel, setSel] = useState<Set<number>>(new Set());
  const [page, setPage] = useState(0);
  const [size, setSize] = useState(6);
  const [menu, setMenu] = useState<number | 'cols' | null>(null);
  const root = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!loading) return;
    const id = window.setTimeout(() => setLoading(false), 900);
    return () => clearTimeout(id);
  }, [loading]);
  useEffect(() => {
    if (menu === null) return;
    const close = (e: PointerEvent) => !(e.target as HTMLElement).closest('[data-menu]') && setMenu(null);
    const key = (e: KeyboardEvent) => e.key === 'Escape' && setMenu(null);
    document.addEventListener('pointerdown', close);
    document.addEventListener('keydown', key);
    return () => {
      document.removeEventListener('pointerdown', close);
      document.removeEventListener('keydown', key);
    };
  }, [menu]);

  const filtered = useMemo(() => {
    const s = q.trim().toLowerCase();
    const list = rows.filter((r) => !s || `${r.name} ${r.email} ${r.plan} ${r.status}`.toLowerCase().includes(s));
    return [...list].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))) * sort.dir;
    });
  }, [rows, q, sort]);
  const pages = Math.max(1, Math.ceil(filtered.length / size));
  const cur = Math.min(page, pages - 1);
  const view = filtered.slice(cur * size, cur * size + size);
  const allOnPage = view.length > 0 && view.every((r) => sel.has(r.id));

  const toggleSort = (key: ColKey) => setSort((s) => ({ key, dir: s.key === key ? ((s.dir * -1) as 1 | -1) : 1 }));
  const visible = COLS.filter((c) => !hidden.has(c.key));
  const fmt = (r: Row, k: (typeof COLS)[number]['key']) =>
    k === 'status' ? <span className={cn('rounded-full px-2 py-0.5 text-[11px] font-medium', BADGE[r.status])}>{r.status}</span> : k === 'mrr' ? <span className="font-mono tabular-nums">${r.mrr.toLocaleString()}</span> : k === 'seats' ? <span className="font-mono tabular-nums">{r.seats}</span> : k === 'joined' ? <span className="font-mono text-xs">{r.joined}</span> : r[k];

  const Head = ({ k, label, className }: { k: ColKey; label: string; className?: string }) => (
    <th scope="col" aria-sort={sort.key === k ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'} className={cn('px-3 py-2.5 text-left font-medium', className)}>
      <button onClick={() => toggleSort(k)} className="focus-ring inline-flex items-center gap-1 rounded hover:text-ink-100">
        {label}
        {sort.key === k && (sort.dir === 1 ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
      </button>
    </th>
  );

  return (
    <div ref={root} className="w-full max-w-4xl rounded-2xl bg-ink-900 shadow-card">
      <div className="flex flex-wrap items-center gap-2 border-b border-white/5 p-3">
        <label className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border border-white/10 bg-ink-950/60 px-3 focus-within:border-brand-400/50 sm:max-w-xs">
          <Search className="h-4 w-4 shrink-0 text-ink-500" />
          <input value={q} onChange={(e) => (setQ(e.target.value), setPage(0))} placeholder="Search customers…" aria-label="Search" className="h-full min-w-0 flex-1 bg-transparent text-sm text-ink-50 outline-none placeholder:text-ink-500" />
        </label>
        {sel.size > 0 && (
          <div className="flex items-center gap-2 rounded-lg bg-brand-500/10 px-2.5 py-1.5 text-xs text-brand-200 animate-scale-in">
            {sel.size} selected
            <button onClick={() => (setRows((r) => r.filter((x) => !sel.has(x.id))), setSel(new Set()))} className="focus-ring inline-flex items-center gap-1 rounded text-rose-300 hover:text-rose-200">
              <Trash2 className="h-3.5 w-3.5" /> Delete
            </button>
          </div>
        )}
        <div className="relative ml-auto" data-menu>
          <button onClick={() => setMenu(menu === 'cols' ? null : 'cols')} aria-expanded={menu === 'cols'} className="focus-ring inline-flex h-9 items-center gap-1.5 rounded-lg border border-white/10 px-3 text-sm text-ink-200 hover:bg-white/5">
            <Columns3 className="h-4 w-4" /> <span className="hidden sm:inline">Columns</span>
          </button>
          {menu === 'cols' && (
            <div className="absolute right-0 top-11 z-20 w-44 rounded-xl border border-white/10 bg-ink-850 p-1.5 shadow-pop animate-scale-in">
              {COLS.map((c) => (
                <label key={c.key} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-ink-200 hover:bg-white/5">
                  <input
                    type="checkbox"
                    checked={!hidden.has(c.key)}
                    onChange={() =>
                      setHidden((h) => {
                        const n = new Set(h);
                        n.has(c.key) ? n.delete(c.key) : n.add(c.key);
                        return n;
                      })
                    }
                    className="accent-sky-400"
                  />
                  {c.label}
                </label>
              ))}
            </div>
          )}
        </div>
      </div>

      <div className="overflow-x-auto">
        <table className="w-full min-w-[560px] text-sm">
          <thead className="text-xs text-ink-500">
            <tr className="border-b border-white/5">
              <th className="w-10 px-3 py-2.5">
                <input type="checkbox" aria-label="Select page" checked={allOnPage} onChange={() => setSel((s) => { const n = new Set(s); view.forEach((r) => (allOnPage ? n.delete(r.id) : n.add(r.id))); return n; })} className="accent-sky-400" />
              </th>
              <Head k="name" label="Customer" />
              {visible.map((c) => (
                <Head key={c.key} k={c.key} label={c.label} className={c.key === 'email' ? 'hidden md:table-cell' : undefined} />
              ))}
              <th className="w-10" />
            </tr>
          </thead>
          <tbody>
            {loading
              ? Array.from({ length: size }, (_, i) => (
                  <tr key={i} className="border-b border-white/5">
                    <td colSpan={visible.length + 3} className="px-3 py-3">
                      <div className="h-4 animate-pulse rounded bg-white/5" style={{ width: `${60 + ((i * 17) % 35)}%` }} />
                    </td>
                  </tr>
                ))
              : view.map((r) => (
                  <tr key={r.id} className={cn('border-b border-white/5 transition-colors', sel.has(r.id) ? 'bg-brand-500/[0.05]' : 'hover:bg-white/[0.02]')}>
                    <td className="px-3 py-2.5">
                      <input type="checkbox" aria-label={`Select ${r.name}`} checked={sel.has(r.id)} onChange={() => setSel((s) => { const n = new Set(s); n.has(r.id) ? n.delete(r.id) : n.add(r.id); return n; })} className="accent-sky-400" />
                    </td>
                    <td className="px-3 py-2.5 font-medium text-ink-100">{r.name}</td>
                    {visible.map((c) => (
                      <td key={c.key} className={cn('px-3 py-2.5 text-ink-300', c.key === 'email' && 'hidden md:table-cell')}>
                        {fmt(r, c.key)}
                      </td>
                    ))}
                    <td className="relative px-2" data-menu>
                      <button aria-label={`Actions for ${r.name}`} onClick={() => setMenu(menu === r.id ? null : r.id)} className="focus-ring grid h-7 w-7 place-items-center rounded-md text-ink-500 hover:bg-white/5 hover:text-ink-100">
                        <MoreHorizontal className="h-4 w-4" />
                      </button>
                      {menu === r.id && (
                        <div role="menu" className="absolute right-2 top-9 z-20 w-36 rounded-xl border border-white/10 bg-ink-850 p-1 shadow-pop animate-scale-in">
                          {[
                            [Pencil, 'Edit'],
                            [Copy, 'Duplicate'],
                          ].map(([I, l]) => {
                            const Icon = I as typeof Pencil;
                            return (
                              <button key={l as string} role="menuitem" onClick={() => setMenu(null)} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-ink-200 hover:bg-white/5">
                                <Icon className="h-3.5 w-3.5" /> {l as string}
                              </button>
                            );
                          })}
                          <button role="menuitem" onClick={() => (setRows((list) => list.filter((x) => x.id !== r.id)), setMenu(null))} className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 hover:bg-rose-500/10">
                            <Trash2 className="h-3.5 w-3.5" /> Delete
                          </button>
                        </div>
                      )}
                    </td>
                  </tr>
                ))}
            {!loading && !view.length && (
              <tr>
                <td colSpan={visible.length + 3} className="px-3 py-12 text-center text-sm text-ink-500">
                  No customers match “{q}”.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      </div>

      <div className="flex flex-wrap items-center justify-between gap-3 px-3 py-3 text-xs text-ink-400">
        <label className="flex items-center gap-2">
          Rows
          <select value={size} onChange={(e) => (setSize(+e.target.value), setPage(0))} className="focus-ring h-7 rounded-md border border-white/10 bg-ink-950 px-1.5 text-ink-200">
            {[6, 10, 20].map((n) => (
              <option key={n}>{n}</option>
            ))}
          </select>
        </label>
        <div className="flex items-center gap-2">
          <span className="tabular-nums">
            {filtered.length ? cur * size + 1 : 0}–{Math.min(filtered.length, cur * size + size)} of {filtered.length}
          </span>
          <button aria-label="Previous page" disabled={cur === 0} onClick={() => setPage(cur - 1)} className="focus-ring grid h-7 w-7 place-items-center rounded-md border border-white/10 hover:bg-white/5 disabled:opacity-30">
            <ChevronLeft className="h-4 w-4" />
          </button>
          <button aria-label="Next page" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)} className="focus-ring grid h-7 w-7 place-items-center rounded-md border border-white/10 hover:bg-white/5 disabled:opacity-30">
            <ChevronRight className="h-4 w-4" />
          </button>
        </div>
      </div>
    </div>
  );
}

export function DataTableProDemo() {
  return <DataTablePro loadingFirst={false} />;
}