v1.0

DataTable

Preview

Basic

Loading…

Preview

Every feature

Loading…

Preview

Loading and empty

Loading…

Preview

Code

ts
import DataTable from '@/components/table/DataTable';

src/components/table/DataTable.tsx

AI prompt

text
Build a generic data table component in React + TypeScript + Tailwind CSS, configured entirely by a column array: sorting, one global search, row selection with a bulk-action bar, pinned rows, pinned columns on either side, resizable columns, a column-visibility menu, striped rows, paging, and loading / empty states.

## Look
- Outer card: the opaque floating surface (`bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no blur), `flex min-h-0 flex-col`. Stacked inside: toolbar, scroll box, selection bar, footer, pagination — each bar `shrink-0` with a slate-200 / dark slate-700 hairline towards the scroll box.
- Toolbar (only if searchable, column toggle or `header` is set): `flex flex-wrap items-center gap-2 border-b px-3 py-2` holding the `header` node, the search input (`relative min-w-48 flex-1`, 14px Search icon absolutely placed at left-2.5, input `pl-8`), then a Columns3 icon button (`shrink-0 rounded-md p-1.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200`).
- Scroll box: `min-h-0 flex-1 overflow-auto`, thin 5px scrollbar (slate-300 thumb, dark slate-700); `maxHeight` applied inline, otherwise the table grows.
- Table: `w-full text-left text-xs`, `table-layout: fixed` when resizable, else auto. `tbody` has `divide-y divide-slate-100 dark:divide-slate-800`, text slate-700 / dark slate-200. Row height comes from line-height, not padding: body cells `px-3 py-0 leading-[40px] truncate` (right-aligned for `align: 'right'`). Header cells never wrap.
- Header cells: sticky `top-0 z-10`, `bg-slate-50/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-slate-200/80 dark:border-slate-700` — background and border on the `th`, not the `tr`, or they scroll away. Text 10px semibold uppercase `tracking-wider` slate-600 / dark slate-300. The cell is `p-0` and holds a full-width button `px-3 py-2.5 leading-4` with a truncating label.
- Sort indicator: a 12px icon after the label, ALWAYS rendered so the header never changes width on click — ArrowUp / ArrowDown on the sorted column (whose label turns slate-800 / dark slate-100), otherwise ChevronsUpDown at opacity 0 that shows at 40% on header hover. Unsortable columns: no icon, default cursor.
- Rows: hover `bg-slate-50 dark:bg-slate-800/50`; striped `odd:bg-slate-50/70 dark:odd:bg-slate-800/30`; selected `bg-indigo-50/60 dark:bg-indigo-500/10`, declared after the stripe so selection wins.
- Checkbox column: 40px, native checkboxes with `accent-indigo-600`, sticky at left 0 (header cell `z-[3] bg-slate-50 dark:bg-slate-900`).
- Pinned columns: `sticky z-[2] bg-white dark:bg-slate-900` (opaque, or scrolled cells show through) with a slate-200 / dark slate-700 border on the inner edge; their header cells `z-[3]`.
- Pinned rows: a separate `tbody` above the body with `border-b-2 border-indigo-200 dark:border-indigo-500/40`. `position: sticky` is ignored on a `tr`, so every CELL is sticky at `top = measured header height` (ResizeObserver on the thead — it changes with density and wrapping), `z-[1] bg-indigo-50/70 dark:bg-indigo-500/10` (`z-[2]` where it is also a pinned column). No hover, stripe or selected tint.
- Resize handle: 4px strip on each header's right edge, `cursor-col-resize hover:bg-indigo-400`, `bg-indigo-500` while dragging.
- Selection bar (while anything is selected): `border-t bg-indigo-50/60 dark:bg-indigo-500/10 px-4 py-2` — "3 selected" in semibold indigo-700 / dark indigo-300, then right-aligned the caller's actions and a ghost "Clear" button with an X icon.
- Footer bar: `border-t px-4 py-2 text-xs text-slate-600 dark:text-slate-300`.

## Behaviour
- Sorting: only columns with `sortValue`. Clicks cycle ascending → descending → unsorted (back to arrival order). Missing values (null / undefined / '') sort LAST in both directions. The kind is read off the first non-missing value: numbers compare numerically, anything else with `localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })`. Sort a copy; stable. Any sort change resets to page 1.
- Uncontrolled until `sort` is passed; then the table only shows the indicator and calls `onSortChange` — it does not re-sort (server-side sorting).
- Search: case-insensitive substring match against every column's `filterValue` (cells render JSX, so a column is searchable only if it says what its text is). Typing resets to page 1. Deliberately no per-column filter menus.
- Pinned rows (`pinnedRowIds`, in the order given) come from the full `rows` and are excluded from the body, search and paging.
- Selection is controlled. The header checkbox is checked when every row on the CURRENT page is selected and toggles just that page, keeping selections elsewhere.
- Pinned offsets: each left-pinned column's `left` is the sum of the pinned widths before it, seeded with 40 when the checkbox column is on; right-pinned the same from the right. Pinned columns with no `width` count as 160px.
- Resize: on pointerdown record x and width; listen for pointermove / pointerup on `window` (the pointer leaves a 4px target mid-drag); width = start + dx, min `minWidth` (default 64). Set `cursor: col-resize` and `user-select: none` on body while dragging.
- Column menu: portalled to <body>, `position: fixed`, 224px wide, max 288px tall and scrolling, `z-[200]`, `rounded-lg border p-1 shadow-xl`, opaque. Opens 4px below the trigger, flips above if there is more room there, stays 8px inside the viewport, re-places on resize and on scroll in the CAPTURE phase (the table's own scroll doesn't bubble). Items `px-2 py-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800`: a 14px rounded square (indigo-600 fill with white check when visible, slate-300 border when hidden) + label. `alwaysVisible` columns are disabled at 40% opacity.
- Paging (`pageSize` > 0): a bar under the table — left "1–25 of 80" (numbers semibold); right a "Rows" select (25 / 50 / 100 / 250) and first / prev / numbered pages with ellipsis gaps / next / last (14px chevrons, `p-1.5 rounded-lg text-slate-500 hover:bg-slate-100`, disabled 40%; current page `bg-indigo-600 text-white font-semibold`, min-w-[1.75rem]). Hidden when everything fits on one page. Changing size resets to page 1.
- Loading: 4 skeleton rows under the real header (a pulsing `h-3 rounded bg-slate-200 dark:bg-slate-700` bar per cell) so widths don't jump.
- No rows at all: just the card, `p-8`, with a centred `text-sm font-medium` title (`py-16`) and the hint behind an ⓘ tooltip — never a bare header. A search with no matches shows one full-width row: "No rows match" / "Clear the search to see everything."

## API
- `Column<T>`: `key`, `header: ReactNode`, `cell(row)`, `sortValue?(row): string | number | null`, `filterValue?(row): string`, `align?: 'left' | 'right'`, `width?`, `minWidth?`, `pin?: 'left' | 'right'`, `alwaysVisible?`.
- Data: `rows`, `columns`, `getRowId(row): string | number`.
- Selection: `selectable` (false), `selected`, `onSelectedChange`, `selectionActions?(ids): ReactNode`.
- Sorting / paging: `sort?: { key, dir: 'asc' | 'desc' } | null`, `onSortChange`, `pageSize` (0 = off).
- Features: `searchable` (false), `searchPlaceholder` ('Search…'), `resizable`, `columnToggle`, `pinnedRowIds`, `stripedRows` (the only banding option), `loading`.
- Chrome: `header`, `footer`, `emptyTitle` ('Nothing here yet'), `emptyHint`, `maxHeight` (e.g. '20rem'), `className`.

## Accessibility
- `aria-sort` on every header cell; checkboxes labelled "Select all rows on this page" / "Select row"; the resize handle is `role="separator" aria-orientation="vertical"`; the Columns button has `aria-label` and `aria-expanded`; the search input's label is its placeholder.

## Demo
A "Team members" table (title in the `header` slot): Name (pinned left, always visible, medium weight), Email, Team, Role, Status (pill badge with a dot: active emerald, invited indigo, suspended rose), Projects and Spend (right-aligned, `$18,420`; Spend pinned right). Eight people (Ada Lovelace, Grace Hopper, Alan Turing…), searchable, selectable with an "Export N" action, row 1 pinned, resizable, column menu, striped, pageSize 25, maxHeight 20rem, footer "8 members · $82,305 total".

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
  ArrowDown,
  ArrowUp,
  Check,
  ChevronsUpDown,
  Columns3,
  Search,
  X,
} from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
import { nextSort, sortRows, type SortState } from '@/lib/sort';
import EmptyState from '@/components/layout/EmptyState';
import Skeleton from '@/components/data/Skeleton';
import Pagination from './Pagination';

export type ColumnPin = 'left' | 'right';

export type Column<T> = {
  key: string;
  header: React.ReactNode;
  cell: (row: T) => React.ReactNode;
  /** Omit to make the column unsortable. */
  sortValue?: (row: T) => string | number | null;
  /**
   * Plain text for this cell, used by the global search. Cells render nodes, and
   * you cannot match a string against JSX — so a column is searchable only if it
   * can say what its text is.
   */
  filterValue?: (row: T) => string;
  align?: 'left' | 'right';
  /**
   * Width in pixels. Required in practice for a PINNED column: the sticky
   * offset of everything after it is the sum of the widths before it, and a
   * column whose width is "whatever the content needs" has no such number.
   * Pinned columns without one fall back to `DEFAULT_WIDTH`.
   */
  width?: number;
  minWidth?: number;
  pin?: ColumnPin;
  /** Keep out of the column-visibility menu, so it can never be hidden. */
  alwaysVisible?: boolean;
};

const DEFAULT_WIDTH = 160;
const MIN_WIDTH = 64;

/**
 * The table most admin screens actually need: sorting, filtering, search,
 * selection, pinned rows and columns, resizable columns, column visibility and
 * paging — all from a column array, so a new screen is data rather than another
 * hand-rolled `<table>`.
 *
 * Two things are worth knowing before extending it.
 *
 * The BODY SCROLLS, so every menu it opens is PORTALLED to `<body>` and
 * positioned fixed. An absolutely-positioned menu inside a scroll container is
 * clipped by it — the menu opens and is painted away. This is the same rule the
 * rest of the library follows; see the popover note in the README.
 *
 * Sorting and paging are UNCONTROLLED until you pass `sort` or take over, then
 * controlled. Small tables sort a local array and large ones sort on the server,
 * and a component that supports only one of those gets copied and diverged —
 * which is how this library ended up with five paginations.
 */
export default function DataTable<T>({
  rows,
  columns,
  getRowId,
  selectable = false,
  selected,
  onSelectedChange,
  selectionActions,
  sort: controlledSort,
  onSortChange,
  pageSize: initialPageSize = 0,
  searchable = false,
  searchPlaceholder = 'Search…',
  resizable = false,
  columnToggle = false,
  pinnedRowIds,
  loading = false,
  stripedRows = false,
  header,
  footer,
  emptyTitle = 'Nothing here yet',
  emptyHint,
  maxHeight,
  className,
}: {
  rows: T[];
  columns: Column<T>[];
  getRowId: (row: T) => string | number;
  selectable?: boolean;
  selected?: Array<string | number>;
  onSelectedChange?: (next: Array<string | number>) => void;
  /** Rendered in the bar that appears while rows are selected. */
  selectionActions?: (selected: Array<string | number>) => React.ReactNode;
  sort?: SortState;
  onSortChange?: (next: SortState) => void;
  /** 0 disables paging — every row renders. */
  pageSize?: number;
  searchable?: boolean;
  searchPlaceholder?: string;
  resizable?: boolean;
  /** Adds the column-visibility menu. */
  columnToggle?: boolean;
  /** Rows kept above the scroll, in the order given. */
  pinnedRowIds?: Array<string | number>;
  loading?: boolean;
  /**
   * Alternating row backgrounds. Selection and pinning still win over the stripe.
   *
   * The only banding option, deliberately. Vertical gridlines solve the same
   * problem — following one row across a wide table — and offering both invites
   * tables that use each in different places for no reason. Row height is not
   * here either: that is the app-wide density setting, and a per-table size
   * prop would be a second control for one thing.
   */
  stripedRows?: boolean;
  /** A bar above the table, beside the search and column controls. */
  header?: React.ReactNode;
  /** A bar below the table, above the pagination. */
  footer?: React.ReactNode;
  emptyTitle?: string;
  emptyHint?: string;
  /** Caps the scroll box, e.g. `'24rem'`. Without it the table grows. */
  maxHeight?: string;
  className?: string;
}) {
  const [localSort, setLocalSort] = useState<SortState>(null);
  const [page, setPage] = useState(1);
  const [pageSizeState, setPageSizeState] = useState(initialPageSize);
  const [query, setQuery] = useState('');
  const [hidden, setHidden] = useState<string[]>([]);
  const [widths, setWidths] = useState<Record<string, number>>({});
  const [headHeight, setHeadHeight] = useState(0);

  const headRef = useRef<HTMLTableSectionElement>(null);
  const sort = controlledSort !== undefined ? controlledSort : localSort;

  const setSort = (next: SortState) => {
    if (onSortChange) onSortChange(next);
    else setLocalSort(next);
    setPage(1);
  };

  // Pinned rows sit under the sticky header, so they need its exact height.
  // Measured rather than assumed: it changes with density and with wrapping.
  useLayoutEffect(() => {
    const el = headRef.current;
    if (!el) return;
    const measure = () => setHeadHeight(el.getBoundingClientRect().height);
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const visibleColumns = useMemo(
    () => columns.filter((c) => !hidden.includes(c.key)),
    [columns, hidden],
  );

  const widthOf = (c: Column<T>) => widths[c.key] ?? c.width ?? (c.pin ? DEFAULT_WIDTH : undefined);

  /**
   * Sticky offsets for pinned columns: each one starts where the pinned columns
   * before it end. The checkbox column is pinned too when selection is on, so it
   * seeds the left offset.
   */
  const offsets = useMemo(() => {
    const left: Record<string, number> = {};
    const right: Record<string, number> = {};
    let acc = selectable ? 40 : 0;
    for (const c of visibleColumns) {
      if (c.pin !== 'left') continue;
      left[c.key] = acc;
      acc += widthOf(c) ?? DEFAULT_WIDTH;
    }
    acc = 0;
    for (const c of [...visibleColumns].reverse()) {
      if (c.pin !== 'right') continue;
      right[c.key] = acc;
      acc += widthOf(c) ?? DEFAULT_WIDTH;
    }
    return { left, right };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visibleColumns, widths, selectable]);

  /**
   * One search box across every column that can say what its text is.
   *
   * Deliberately not per-column filter menus as well: two filtering mechanisms
   * on one table means two places to look when the rows on screen are not the
   * rows you expected, and a menu buried in a header is the one people forget
   * they left set.
   */
  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return rows;
    return rows.filter((r) =>
      columns.some((c) => c.filterValue && c.filterValue(r).toLowerCase().includes(q)),
    );
  }, [rows, columns, query]);

  // Sort locally only while the caller has NOT taken over — otherwise it is
  // sorting server-side and re-sorting here would fight it.
  const sorted = useMemo(() => {
    if (controlledSort !== undefined || !sort) return filtered;
    const col = columns.find((c) => c.key === sort.key);
    if (!col?.sortValue) return filtered;
    // The kind is read off the first row that has a value — `sortValue` is
    // untyped per column, and guessing "number" from a string column is how
    // "10" ends up before "9". Missing values sort last in both directions.
    const read = col.sortValue;
    let sample: string | number | null = null;
    for (const r of filtered) {
      const v = read(r);
      if (v !== null && v !== undefined && v !== '') { sample = v; break; }
    }
    return sortRows(filtered, sort, (r) => read(r), () => (typeof sample === 'number' ? 'number' : 'text'));
  }, [filtered, columns, sort, controlledSort]);

  const pinnedSet = new Set(pinnedRowIds ?? []);
  const pinnedRows = (pinnedRowIds ?? [])
    .map((id) => rows.find((r) => getRowId(r) === id))
    .filter((r): r is T => !!r);
  const bodyRows = sorted.filter((r) => !pinnedSet.has(getRowId(r)));
  const paged =
    pageSizeState > 0 ? bodyRows.slice((page - 1) * pageSizeState, page * pageSizeState) : bodyRows;

  const ids = paged.map(getRowId);
  const sel = selected ?? [];
  const allOnPage = ids.length > 0 && ids.every((id) => sel.includes(id));
  const toggleAll = () =>
    onSelectedChange?.(
      allOnPage ? sel.filter((id) => !ids.includes(id)) : [...new Set([...sel, ...ids])],
    );

  const colStyle = (c: Column<T>): React.CSSProperties => {
    const w = widthOf(c);
    return {
      ...(w ? { width: w, minWidth: w } : {}),
      ...(c.pin === 'left' ? { left: offsets.left[c.key] } : {}),
      ...(c.pin === 'right' ? { right: offsets.right[c.key] } : {}),
    };
  };

  const pinClass = (c: Column<T>) =>
    c.pin &&
    cn(
      'sticky z-[2] bg-white dark:bg-slate-900',
      c.pin === 'left' ? 'border-r border-slate-200 dark:border-slate-700' : 'border-l border-slate-200 dark:border-slate-700',
    );

  if (!loading && rows.length === 0) {
    return (
      <div className={cn('panel panel-solid p-8', className)}>
        <EmptyState title={emptyTitle} hint={emptyHint} />
      </div>
    );
  }

  const hasToolbar = searchable || columnToggle || !!header;

  return (
    <div className={cn('panel panel-solid flex min-h-0 flex-col', className)}>
      {hasToolbar && (
        <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-700">
          {header}
          {searchable && (
            <div className="relative min-w-48 flex-1">
              <Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
              <input
                value={query}
                onChange={(e) => {
                  setQuery(e.target.value);
                  setPage(1);
                }}
                placeholder={searchPlaceholder}
                aria-label={searchPlaceholder}
                className="field-input pl-8"
              />
            </div>
          )}
          {columnToggle && (
            <HeaderMenu label="Columns" icon={<Columns3 className="h-3.5 w-3.5" />}>
              {columns.map((c) => {
                const on = !hidden.includes(c.key);
                const locked = c.alwaysVisible;
                return (
                  <button
                    key={c.key}
                    disabled={locked}
                    onClick={() =>
                      setHidden(on ? [...hidden, c.key] : hidden.filter((k) => k !== c.key))
                    }
                    className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-800"
                  >
                    <span
                      className={cn(
                        'grid h-3.5 w-3.5 shrink-0 place-items-center rounded border',
                        on
                          ? 'border-indigo-600 bg-indigo-600'
                          : 'border-slate-300 dark:border-slate-600',
                      )}
                    >
                      {on && <Check className="h-2.5 w-2.5 text-white" strokeWidth={3} />}
                    </span>
                    <span className="truncate">{c.header}</span>
                  </button>
                );
              })}
            </HeaderMenu>
          )}
        </div>
      )}

      <div
        className="custom-scrollbar min-h-0 flex-1 overflow-auto"
        style={maxHeight ? { maxHeight } : undefined}
      >
        <table className="data-table" style={{ tableLayout: resizable ? 'fixed' : 'auto' }}>
          <thead ref={headRef}>
            <tr>
              {selectable && (
                <th
                  className="sticky left-0 z-[3] w-10 bg-slate-50 px-3 dark:bg-slate-900"
                  style={{ width: 40, minWidth: 40 }}
                >
                  <input
                    type="checkbox"
                    checked={allOnPage}
                    onChange={toggleAll}
                    aria-label="Select all rows on this page"
                    className="accent-indigo-600"
                  />
                </th>
              )}
              {visibleColumns.map((c) => (
                <th
                  key={c.key}
                  style={colStyle(c)}
                  aria-sort={
                    sort?.key === c.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'
                  }
                  className={cn('relative p-0', c.pin && 'z-[3]', pinClass(c))}
                >
                  <div className={cn('flex items-center', c.align === 'right' && 'justify-end')}>
                    <SortButton
                      column={c}
                      sort={sort}
                      onSort={setSort}
                      disabled={!c.sortValue}
                    />
                    {resizable && (
                      <ResizeHandle
                        width={widthOf(c) ?? DEFAULT_WIDTH}
                        min={c.minWidth ?? MIN_WIDTH}
                        onResize={(w) => setWidths((prev) => ({ ...prev, [c.key]: w }))}
                      />
                    )}
                  </div>
                </th>
              ))}
            </tr>
          </thead>

          {pinnedRows.length > 0 && (
            <tbody className="border-b-2 border-indigo-200 dark:border-indigo-500/40">
              {pinnedRows.map((row) => (
                <Row
                  key={getRowId(row)}
                  row={row}
                  columns={visibleColumns}
                  getRowId={getRowId}
                  selectable={selectable}
                  selected={sel}
                  onSelectedChange={onSelectedChange}
                  colStyle={colStyle}
                  pinClass={pinClass}
                  stickyTop={headHeight}
                />
              ))}
            </tbody>
          )}

          <tbody>
            {loading ? (
              Array.from({ length: 4 }, (_, i) => (
                <tr key={`skeleton-${i}`}>
                  {selectable && <td className="px-3" />}
                  {visibleColumns.map((c) => (
                    <td key={c.key} className="px-3">
                      <Skeleton />
                    </td>
                  ))}
                </tr>
              ))
            ) : paged.length === 0 ? (
              <tr>
                <td colSpan={visibleColumns.length + (selectable ? 1 : 0)} className="px-3 py-8">
                  <EmptyState title="No rows match" hint="Clear the search to see everything." />
                </td>
              </tr>
            ) : (
              paged.map((row) => (
                <Row
                  key={getRowId(row)}
                  row={row}
                  columns={visibleColumns}
                  getRowId={getRowId}
                  selectable={selectable}
                  selected={sel}
                  onSelectedChange={onSelectedChange}
                  colStyle={colStyle}
                  pinClass={pinClass}
                  striped={stripedRows}
                />
              ))
            )}
          </tbody>
        </table>
      </div>

      {selectable && sel.length > 0 && (
        <div className="flex shrink-0 items-center gap-3 border-t border-slate-200 bg-indigo-50/60 px-4 py-2 text-xs dark:border-slate-700 dark:bg-indigo-500/10">
          <span className="font-semibold text-indigo-700 dark:text-indigo-300">
            {sel.length} selected
          </span>
          <div className="ml-auto flex items-center gap-2">
            {selectionActions?.(sel)}
            <button onClick={() => onSelectedChange?.([])} className="btn-ghost">
              <X className="h-3.5 w-3.5" />
              Clear
            </button>
          </div>
        </div>
      )}

      {footer && (
        <div className="shrink-0 border-t border-slate-200 px-4 py-2 text-xs text-slate-600 dark:border-slate-700 dark:text-slate-300">
          {footer}
        </div>
      )}

      {pageSizeState > 0 && (
        <Pagination
          totalItems={bodyRows.length}
          itemsPerPage={pageSizeState}
          currentPage={page}
          onPageChange={setPage}
          onItemsPerPageChange={(n) => {
            setPageSizeState(n);
            setPage(1);
          }}
        />
      )}
    </div>
  );
}

/* -------------------------------------------------------------------------- */

function Row<T>({
  row,
  columns,
  getRowId,
  selectable,
  selected,
  onSelectedChange,
  colStyle,
  pinClass,
  striped,
  stickyTop,
}: {
  row: T;
  columns: Column<T>[];
  getRowId: (row: T) => string | number;
  selectable: boolean;
  selected: Array<string | number>;
  onSelectedChange?: (next: Array<string | number>) => void;
  colStyle: (c: Column<T>) => React.CSSProperties;
  pinClass: (c: Column<T>) => string | false | undefined;
  striped?: boolean;
  /** Set for a pinned row: how far under the header it sticks. */
  stickyTop?: number;
}) {
  const id = getRowId(row);
  const checked = selected.includes(id);
  const pinned = stickyTop !== undefined;

  // A sticky ROW is really sticky CELLS — `position: sticky` on a `<tr>` is
  // ignored, so each cell carries it, and each needs its own background or the
  // rows scrolling underneath show straight through.
  const cellBase = pinned ? 'sticky z-[1] bg-indigo-50/70 dark:bg-indigo-500/10' : '';
  const cellStyle = pinned ? { top: stickyTop } : undefined;

  return (
    <tr
      className={cn(
        // Order matters: a selected row must still read as selected inside a
        // striped table, so the stripe is declared first and overridden.
        striped && !pinned && 'odd:bg-slate-50/70 dark:odd:bg-slate-800/30',
        !pinned && 'hover:bg-slate-50 dark:hover:bg-slate-800/50',
        checked && !pinned && 'bg-indigo-50/60 dark:bg-indigo-500/10',
      )}
    >
      {selectable && (
        <td className={cn('px-3', cellBase, pinned && 'left-0 z-[2]')} style={cellStyle}>
          <input
            type="checkbox"
            checked={checked}
            onChange={() =>
              onSelectedChange?.(checked ? selected.filter((s) => s !== id) : [...selected, id])
            }
            aria-label="Select row"
            className="accent-indigo-600"
          />
        </td>
      )}
      {columns.map((c) => (
        <td
          key={c.key}
          style={{ ...colStyle(c), ...cellStyle }}
          className={cn(
            'truncate px-3',
            c.align === 'right' && 'text-right',
            pinClass(c),
            cellBase,
            c.pin && pinned && 'z-[2]',
          )}
        >
          {c.cell(row)}
        </td>
      ))}
    </tr>
  );
}

function SortButton<T>({
  column,
  sort,
  onSort,
  disabled,
}: {
  column: Column<T>;
  sort: SortState;
  onSort: (next: SortState) => void;
  disabled: boolean;
}) {
  const active = sort?.key === column.key;
  return (
    <button
      type="button"
      disabled={disabled}
      onClick={() => onSort(nextSort(sort, column.key))}
      title={disabled ? undefined : 'Click to sort'}
      className={cn(
        'group/sort flex min-w-0 flex-1 items-center gap-1 px-3 py-2.5 text-left uppercase leading-4 transition-colors',
        !disabled && 'hover:text-slate-800 dark:hover:text-slate-100',
        disabled && 'cursor-default',
        column.align === 'right' && 'justify-end',
        active && 'text-slate-800 dark:text-slate-100',
      )}
    >
      <span className="truncate">{column.header}</span>
      {!disabled && (
        // Always rendered, at zero opacity when unsorted, so the header never
        // changes width on click and the row cannot reflow under the cursor.
        <span
          aria-hidden
          className={cn(
            'shrink-0 transition-opacity',
            active ? 'opacity-100' : 'opacity-0 group-hover/sort:opacity-40',
          )}
        >
          {active ? (
            sort!.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
          ) : (
            <ChevronsUpDown className="h-3 w-3" />
          )}
        </span>
      )}
    </button>
  );
}

function ResizeHandle({
  width,
  min,
  onResize,
}: {
  width: number;
  min: number;
  onResize: (width: number) => void;
}) {
  const [dragging, setDragging] = useState(false);
  const start = useRef({ x: 0, w: 0 });

  useEffect(() => {
    if (!dragging) return;
    // On `window`, not the handle: the pointer routinely leaves a 4px target
    // mid-drag, and a handle-scoped listener drops the gesture when it does.
    const move = (e: PointerEvent) =>
      onResize(Math.max(min, start.current.w + (e.clientX - start.current.x)));
    const stop = () => setDragging(false);
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', stop);
    const prev = document.body.style.cursor;
    document.body.style.cursor = 'col-resize';
    document.body.style.userSelect = 'none';
    return () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', stop);
      document.body.style.cursor = prev;
      document.body.style.userSelect = '';
    };
  }, [dragging, min, onResize]);

  return (
    <span
      role="separator"
      aria-orientation="vertical"
      onPointerDown={(e) => {
        e.preventDefault();
        start.current = { x: e.clientX, w: width };
        setDragging(true);
      }}
      className={cn(
        'absolute inset-y-0 right-0 w-1 cursor-col-resize transition-colors',
        dragging ? 'bg-indigo-500' : 'hover:bg-indigo-400',
      )}
    />
  );
}

/**
 * A menu anchored to its trigger and PORTALLED to `<body>`.
 *
 * The table body is a scroll container, and an absolutely-positioned menu inside
 * one is clipped by it — the menu opens and is painted away. Portalling with
 * `position: fixed` is the only arrangement that survives, and it also clears
 * the sticky header and pinned columns without a z-index race.
 */
function HeaderMenu({
  label,
  icon,
  active,
  children,
}: {
  label: string;
  icon: React.ReactNode;
  active?: boolean;
  children: React.ReactNode;
}) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState<{ top: number; left: number; maxHeight: number } | null>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    if (!open) return;
    const place = () => {
      const el = trigger.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const width = 224;
      const EDGE = 8;
      const GAP = 4;
      // Below the trigger when the menu fits there, above when it only fits
      // above — a table near the foot of the window otherwise opens its menu
      // off-screen. Either way it is capped to the room it has and scrolls.
      const wanted = Math.min(panel.current?.scrollHeight ?? 288, 288);
      const below = window.innerHeight - r.bottom - GAP - EDGE;
      const above = r.top - GAP - EDGE;
      const up = below < wanted && above > below;
      const room = Math.max(120, up ? above : below);
      const height = Math.min(wanted, room);
      setPos({
        top: up ? r.top - GAP - height : r.bottom + GAP,
        left: Math.min(Math.max(r.left, EDGE), Math.max(EDGE, window.innerWidth - width - EDGE)),
        maxHeight: room,
      });
    };
    place();
    // Once more after the menu has mounted, with its real height.
    const frame = requestAnimationFrame(place);
    // Capture phase: a scroll inside the table's own box does not bubble.
    window.addEventListener('scroll', place, true);
    window.addEventListener('resize', place);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', place, true);
      window.removeEventListener('resize', place);
    };
  }, [open]);

  useDismiss([trigger, panel], open, () => setOpen(false));

  return (
    <>
      <button
        ref={trigger}
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-label={label}
        aria-expanded={open}
        className={cn(
          'shrink-0 rounded-md p-1.5 transition-colors',
          active
            ? 'text-indigo-600 dark:text-indigo-400'
            : 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200',
        )}
      >
        {icon}
      </button>
      {open &&
        pos &&
        typeof document !== 'undefined' &&
        createPortal(
          <div
            ref={panel}
            style={{ top: pos.top, left: pos.left, width: 224, maxHeight: Math.min(288, pos.maxHeight) }}
            // The portalled-overlay band (see globals.css): above sticky table
            // chrome and every in-flow popover.
            className="custom-scrollbar fixed z-[200] overflow-y-auto rounded-lg border border-slate-200 bg-white p-1 shadow-xl dark:border-slate-700 dark:bg-slate-800"
          >
            {children}
          </div>,
          document.body,
        )}
    </>
  );
}

Props

PropTypeDefaultDescription
rows*T[]—
columns*Column<T>[]—
getRowId*(row: T) => string | number—
selectableboolean—
selectedArray<string | number>—
onSelectedChange(next: Array<string | number>) => void—
selectionActions(selected: Array<string | number>) => React.ReactNode—Rendered in the bar that appears while rows are selected.
sortSortState—
onSortChange(next: SortState) => void—
pageSizenumber—0 disables paging — every row renders.
searchableboolean—
searchPlaceholderstring—
resizableboolean—
columnToggleboolean—Adds the column-visibility menu.
pinnedRowIdsArray<string | number>—Rows kept above the scroll, in the order given.
loadingboolean—
stripedRowsboolean—Alternating row backgrounds. Selection and pinning still win over the stripe. The only banding option, deliberately. Vertical gridlines solve the same problem — following one row across a wide table — and offering both invites tables that use each in different places for no reason. Row height is not here either: that is the app-wide density setting, and a per-table size prop would be a second control for one thing.
headerReact.ReactNode—A bar above the table, beside the search and column controls.
footerReact.ReactNode—A bar below the table, above the pagination.
emptyTitlestring—
emptyHintstring—
maxHeightstring—Caps the scroll box, e.g. `'24rem'`. Without it the table grows.
classNamestring—