Headless table with Tailwind

When your design system is Tailwind and you want to own every element, skip <DataTable /> and build the table from the headless hooks. The hooks hold sort, page, selection, and filter state. You write a plain <table> and style it with utility classes.

If <DataTable /> with custom styles or a theme gets you there, use that instead. It is far less code, and the header, checkboxes, and pagination come with their accessibility wiring.

Headless table with Tailwind

Sort by clicking a header, search and filter by status, select rows, and page through the results. Every element and class is in the demo source.

14 of 14 invoices

Atlas CartographyTeamPaid$480
Bluebird BakeryStarterOverdue$96
Copperline CoffeeTeamPaid$480
Fernhill NurseryStarterOverdue$120
Harbor Freight StudioEnterprisePaid$2,400
Page 1 of 3
{"sort":"customer asc","filters":{"customer":"","status":""},"page":1,"selected":[]}

Import from the headless entry

import {
  useColumns,
  useTableState,
  useTableData,
  useColumnFilter,
  SortOrder,
  type TableColumn,
} from 'react-data-table-component/headless';

react-data-table-component/headless contains the hooks only. It does not load <DataTable /> or inject the library stylesheet, so nothing competes with your utility classes.

The pipeline

Four hooks, in this order. Filter the full sorted set first and slice the page last, so a search finds rows on every page.

const { tableColumns, defaultSortColumn, defaultSortDirection } = useColumns(columns, {
  defaultSortFieldId: 'customer',
});

const { tableState, handleSort, handleChangePage, handleSelectedRow, handleSelectAllRows } =
  useTableState({ data, defaultSortColumn, defaultSortDirection, paginationPerPage: 5 });

const { selectedColumn, sortDirection, sortColumns, currentPage, rowsPerPage } = tableState;

const { sortedData } = useTableData({
  data, selectedColumn, sortDirection, sortColumns, currentPage, rowsPerPage,
});

const { filterValues, handleFilterChange, filteredData } = useColumnFilter(tableColumns);
const filteredRows = filteredData(sortedData);
const rows = filteredRows.slice((currentPage - 1) * rowsPerPage, currentPage * rowsPerPage);

Sortable headers

Put a real <button> inside each <th> and set aria-sort on the header cell. The button gives you keyboard support for free. Clicking the same column again cycles ascending, descending, then back to unsorted. Add direction: SortOrder.DESC (or ASC) to the action to force a direction instead.

<th scope="col" aria-sort={ariaSort} className="px-3 py-2.5">
  <button
    type="button"
    onClick={() =>
      handleSort({
        type: 'SORT_CHANGE',
        selectedColumn: col,
        clearSelectedOnSort: false,
        additive: false,
        defaultSortDirection: SortOrder.ASC,
      })
    }
    className="inline-flex items-center gap-1 hover:text-gray-900 focus-visible:ring-2 focus-visible:ring-blue-500"
  >
    {col.name}
  </button>
</th>

Row selection

Selection is driven by actions. For a row checkbox, pass the row and whether it is selected before the click. rowCount is the number of rows "select all" covers, which is how the hook knows when to set tableState.allSelected.

const isSelected = (row: Invoice) => tableState.selectedRows.some(r => r.id === row.id);

<input
  type="checkbox"
  className="size-4 rounded border-gray-300 accent-blue-600"
  checked={isSelected(row)}
  onChange={() =>
    handleSelectedRow({
      type: 'SELECT_SINGLE_ROW',
      keyField: 'id',
      row,
      isSelected: isSelected(row),
      rowCount: filteredRows.length,
      singleSelect: false,
    })
  }
/>

The header checkbox toggles between every filtered row and none. Set indeterminate through a ref, since React has no prop for it.

<input
  type="checkbox"
  checked={tableState.allSelected}
  ref={el => {
    if (el) el.indeterminate = tableState.selectedRows.length > 0 && !tableState.allSelected;
  }}
  onChange={() =>
    handleSelectAllRows({
      type: 'SELECT_ALL_ROWS',
      keyField: 'id',
      rows: filteredRows,
      rowCount: filteredRows.length,
      mergeSelections: false,
    })
  }
/>

Filters reset the page and the selection

A filter changes how many pages there are and which rows "select all" covers. Go back to page 1 and clear the selection whenever one changes, so neither points at rows that are no longer shown.

function applyFilter(columnId: string, operator: 'contains' | 'equals', value: string) {
  handleFilterChange(columnId, { condition1: { operator, value } });
  handleClearSelectedRows();
  handleChangePage(1);
}

<input
  type="search"
  value={filterValues.customer?.condition1.value ?? ''}
  onChange={e => applyFilter('customer', 'contains', e.target.value)}
  className="rounded-md border border-gray-300 px-3 py-1.5 focus:ring-2 focus:ring-blue-500/30"
/>

What you take on

  • Markup and semantics: <table>, scope, aria-sort, and labels on every checkbox and input.
  • Empty, loading, and error states.
  • Responsive behavior, such as the overflow-x-auto wrapper in the demo.
  • Dark mode, with Tailwind's dark: variants on the same elements.

For keyboard grid navigation, column resizing, and pinning on your own markup, see the useCellNavigation, useColumnResize, and pinning helpers on the hook reference.