Skip to content
Plinth

Cards

Data Table

A useful records table with text search, numeric sorting, pagination and explicit page-scoped selection, ready for real application data.

#table#data#search#sorting#pagination#dashboard

Install

npx shadcn@latest add https://plinthui.com/r/data-table.json
Open in v0
'use client';

import { useId, useRef, useState, type ReactNode } from 'react';

const css = `.dt-root {
  --dt-ink: #27382f; --dt-muted: #66766a; --dt-paper: #fafbf6; --dt-soft: #f0f3e9; --dt-line: #dbe3d4; --dt-accent: #476b32; --dt-pill: #e7eedc;
  width: min(100%, 640px); min-width: 0; color: var(--dt-ink); background: var(--dt-paper); border: 1px solid var(--dt-line); border-radius: 18px; box-shadow: 0 10px 30px #2130200b; font: 13px/1.5 system-ui, sans-serif;
}
.dt-root * { box-sizing: border-box; }
.dt-root button, .dt-root input { font: inherit; }
.dt-root button { cursor: pointer; }
.dt-root button:disabled { cursor: default; opacity: .4; }
.dt-root button:focus-visible, .dt-root input:focus-visible, .dt-scroll:focus-visible { outline: 2px solid var(--dt-accent); outline-offset: 3px; }
.dt-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 17px 18px; }
.dt-heading { min-width: 0; display: flex; align-items: center; gap: 9px; }
.dt-title { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: -.025em; overflow-wrap: anywhere; }
.dt-total { display: inline-grid; place-items: center; min-width: 23px; padding: 2px 6px; border-radius: 6px; color: var(--dt-accent); background: var(--dt-pill); font: 10px/1.5 ui-monospace, monospace; }
.dt-search { display: flex; align-items: center; gap: 7px; width: min(220px, 48%); min-width: 130px; border: 1px solid var(--dt-line); border-radius: 8px; padding: 5px 8px; background: var(--dt-paper); }
.dt-search svg { flex: none; width: 15px; height: 15px; color: var(--dt-muted); }
.dt-search input { width: 100%; min-width: 0; height: 23px; padding: 0; border: 0; outline: 0; background: transparent; color: var(--dt-ink); font-size: 11px; }
.dt-search input::placeholder { color: var(--dt-muted); }
.dt-search:focus-within { outline: 2px solid var(--dt-accent); outline-offset: 2px; }
.dt-search input:focus-visible { outline: none; }
.dt-search-clear { flex: none; display: grid; place-items: center; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 4px; color: var(--dt-muted); background: transparent; }
.dt-search-clear[hidden], .dt-selected[hidden] { display: none; }
.dt-selected { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 18px; border-top: 1px solid var(--dt-line); color: var(--dt-accent); background: var(--dt-soft); font-size: 11px; }
.dt-clear { padding: 2px 0; border: 0; color: inherit; background: transparent; font-size: 11px !important; text-decoration: underline; text-underline-offset: 3px; }
.dt-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; border-top: 1px solid var(--dt-line); border-bottom: 1px solid var(--dt-line); }
.dt-table { width: 100%; min-width: 440px; border-spacing: 0; border-collapse: collapse; table-layout: fixed; text-align: left; }
.dt-table th { padding: 10px 14px; color: var(--dt-muted); background: var(--dt-soft); font-size: 10px; font-weight: 600; }
.dt-table td { padding: 13px 14px; border-top: 1px solid var(--dt-line); font-size: 12px; overflow-wrap: anywhere; }
.dt-table tbody tr[data-selected="true"] { background: var(--dt-soft); }
.dt-table th:first-child, .dt-table td:first-child { padding-left: 18px; }
.dt-table th:last-child, .dt-table td:last-child { padding-right: 18px; }
.dt-table .dt-check-cell { width: 42px; padding-right: 2px; }
.dt-check { display: block; width: 14px; height: 14px; margin: 0; accent-color: var(--dt-accent); cursor: pointer; }
.dt-sort { display: inline-flex; align-items: center; gap: 5px; border: 0; border-radius: 3px; background: transparent; color: inherit; font: inherit; padding: 0; text-align: inherit; }
.dt-sort span { color: var(--dt-accent); font-size: 12px; }
.dt-table [data-align="right"] { text-align: right; font-variant-numeric: tabular-nums; }
.dt-table [data-primary="true"] { font-weight: 550; letter-spacing: -.015em; }
.dt-pill { display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px; border: 1px solid var(--dt-line); border-radius: 5px; color: var(--dt-muted); background: var(--dt-soft); font-size: 10px; white-space: nowrap; }
.dt-pill::before { content: ''; width: 4px; height: 4px; border-radius: 50%; background: currentColor; }
.dt-pill[data-value="active"], .dt-pill[data-value="published"] { color: var(--dt-accent); background: var(--dt-pill); }
.dt-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 12px 18px; }
.dt-range { margin: 0; color: var(--dt-muted); font-size: 10px; }
.dt-pages { display: flex; align-items: center; gap: 8px; color: var(--dt-muted); font-size: 10px; }
.dt-page { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--dt-line); border-radius: 6px; background: transparent; color: var(--dt-ink); }
.dt-page:not(:disabled):hover, .dt-search-clear:hover { background: var(--dt-soft); }
.dt-empty { text-align: center; padding: 30px 18px !important; }
.dt-empty strong { display: block; font-size: 13px; font-weight: 600; }
.dt-empty p { margin: 4px 0 0; color: var(--dt-muted); font-size: 11px; }
.dt-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (max-width: 420px) { .dt-toolbar { align-items: stretch; flex-direction: column; gap: 12px; } .dt-search { width: 100%; } }
:root[data-theme="dark"] .dt-root { --dt-ink: #e6ecdf; --dt-muted: #a2af9c; --dt-paper: #232c23; --dt-soft: #2c372a; --dt-line: #3e4b37; --dt-accent: #bedc96; --dt-pill: #35472b; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .dt-root { --dt-ink: #e6ecdf; --dt-muted: #a2af9c; --dt-paper: #232c23; --dt-soft: #2c372a; --dt-line: #3e4b37; --dt-accent: #bedc96; --dt-pill: #35472b; } }
`;

type Column<Row> = {
  key: keyof Row & string;
  label: string;
  sortable?: boolean;
  align?: 'left' | 'right';
  format?: 'text' | 'badge';
  /** Render changes display only; search/sort still use the raw property. */
  render?: (row: Row) => ReactNode;
};

export function DataTable<Row extends { id: string }>({
  rows, columns, title = 'Records', pageSize = 4, selectedIds = [], onSelectionChange,
}: {
  rows: Row[];
  columns: Column<Row>[];
  title?: string;
  pageSize?: number;
  /** Controlled selection. IDs must be unique across all rows. */
  selectedIds?: string[];
  /** Providing this enables selection. Header checkbox affects this page only. */
  onSelectionChange?: (ids: string[]) => void;
}) {
  const id = useId();
  const search = useRef<HTMLInputElement>(null);
  const [query, setQuery] = useState('');
  const [page, setPage] = useState(0);
  const [sort, setSort] = useState<{ key: keyof Row & string; direction: 'asc' | 'desc' } | null>(null);
  const [announcement, setAnnouncement] = useState('');
  const size = Number.isFinite(pageSize) ? Math.max(1, Math.floor(pageSize)) : 4;
  const tokens = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
  const activeSort = sort && columns.some(column => column.key === sort.key && column.sortable !== false) ? sort : null;
  const filtered = rows.filter(row => {
    const text = columns.map(column => String(row[column.key] ?? '')).join(' ').toLocaleLowerCase();
    return tokens.every(token => text.includes(token));
  });
  if (activeSort) filtered.sort((a, b) => {
    const first = a[activeSort.key], second = b[activeSort.key];
    const result = typeof first === 'number' && typeof second === 'number' ? first - second : String(first ?? '').localeCompare(String(second ?? ''), undefined, { numeric: true, sensitivity: 'base' });
    return activeSort.direction === 'asc' ? result : -result;
  });
  const pages = Math.max(1, Math.ceil(filtered.length / size));
  const current = Math.min(page, pages - 1);
  // Commit the clamp, so restoring a larger dataset cannot revive an old page.
  if (page !== current) setPage(current);
  const visible = filtered.slice(current * size, (current + 1) * size);
  const available = new Set(rows.map(row => row.id));
  const selection = new Set(selectedIds.filter(rowId => available.has(rowId)));
  const onPage = visible.filter(row => selection.has(row.id)).length;

  function changeQuery(value: string) { setQuery(value); setPage(0); }
  function selectPage(checked: boolean) {
    visible.forEach(row => checked ? selection.add(row.id) : selection.delete(row.id));
    onSelectionChange?.([...selection]);
    setAnnouncement(`${selection.size} rows selected across all pages.`);
  }

  return <>
    <style>{css}</style>
    <section className="dt-root" aria-labelledby={`${id}-title`}>
      <div className="dt-toolbar">
        <div className="dt-heading"><h3 className="dt-title" id={`${id}-title`}>{title}</h3><span className="dt-total" aria-label={`${rows.length} records`}>{rows.length}</span></div>
        <div className="dt-search">
          <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="8" cy="8" r="5" /><path d="m12 12 5 5" /></svg>
          <input ref={search} type="search" aria-label={`Search ${title}`} placeholder="Search records…" value={query} onChange={event => changeQuery(event.target.value)} />
          {query && <button type="button" className="dt-search-clear" aria-label="Clear search" onClick={() => { changeQuery(''); search.current?.focus(); }}>×</button>}
        </div>
      </div>
      {onSelectionChange && selection.size > 0 && <div className="dt-selected"><span>{selection.size} selected across all pages</span><button type="button" className="dt-clear" onClick={() => { onSelectionChange([]); setAnnouncement('Selection cleared.'); search.current?.focus(); }}>Clear selection</button></div>}
      <div className="dt-scroll" role="region" aria-label={`${title} table, scroll horizontally on small screens`} tabIndex={0}>
        <table className="dt-table">
          <caption className="dt-sr">{title}. Use column buttons to sort. {onSelectionChange ? 'The header checkbox selects only the current page.' : ''}</caption>
          <thead><tr>
            {onSelectionChange && <th scope="col" className="dt-check-cell"><input className="dt-check" type="checkbox" aria-label={`Select this page (${visible.length} rows)`} disabled={!visible.length} checked={visible.length > 0 && onPage === visible.length} ref={node => { if (node) node.indeterminate = onPage > 0 && onPage < visible.length; }} onChange={event => selectPage(event.target.checked)} /></th>}
            {columns.map(column => <th scope="col" key={column.key} data-align={column.align} aria-sort={column.sortable === false ? undefined : activeSort?.key === column.key ? activeSort.direction === 'asc' ? 'ascending' : 'descending' : 'none'}>
              {column.sortable === false ? column.label : <button type="button" className="dt-sort" aria-label={`Sort by ${column.label}`} onClick={() => {
                const direction = activeSort?.key === column.key && activeSort.direction === 'asc' ? 'desc' : 'asc';
                setSort({ key: column.key, direction }); setPage(0); setAnnouncement(`Sorted by ${column.label}, ${direction === 'asc' ? 'ascending' : 'descending'}.`);
              }}>{column.label}<span aria-hidden="true">{activeSort?.key === column.key ? activeSort.direction === 'asc' ? '↑' : '↓' : '↕'}</span></button>}
            </th>)}
          </tr></thead>
          <tbody>{visible.length ? visible.map(row => <tr key={row.id} data-selected={selection.has(row.id)}>
            {onSelectionChange && <td className="dt-check-cell"><input className="dt-check" type="checkbox" checked={selection.has(row.id)} aria-label={`Select ${String(row[columns[0]?.key] ?? row.id)}`} onChange={event => { if (event.target.checked) selection.add(row.id); else selection.delete(row.id); onSelectionChange([...selection]); setAnnouncement(`${selection.size} rows selected across all pages.`); }} /></td>}
            {columns.map((column, index) => <td key={column.key} data-align={column.align} data-primary={index === 0}>{column.render ? column.render(row) : column.format === 'badge' ? <span className="dt-pill" data-value={String(row[column.key]).toLowerCase()}>{String(row[column.key] ?? '')}</span> : String(row[column.key] ?? '')}</td>)}
          </tr>) : <tr><td className="dt-empty" colSpan={Math.max(1, columns.length + (onSelectionChange ? 1 : 0))}><strong>{query ? 'No matching records' : 'No records yet'}</strong><p>{query ? 'Try another search or clear your filters.' : 'Add records to populate this table.'}</p></td></tr>}</tbody>
        </table>
      </div>
      <div className="dt-footer">
        <p className="dt-range" role="status">{filtered.length ? `${current * size + 1}–${Math.min((current + 1) * size, filtered.length)} of ${filtered.length} records` : '0 records'}{query ? ' matching search' : ''}</p>
        <div className="dt-pages"><span>Page {current + 1} of {pages}</span><button type="button" className="dt-page" aria-label="Previous page" disabled={!current} onClick={() => setPage(current - 1)}>‹</button><button type="button" className="dt-page" aria-label="Next page" disabled={current >= pages - 1} onClick={() => setPage(current + 1)}>›</button></div>
      </div>
      <p className="dt-sr" role="status">{announcement}</p>
    </section>
  </>;
}

More in Cards

Similar tags