react-glide-table
v2.1.0
Published
Headless React table built on [@tanstack/react-table](https://tanstack.com/table) and [@tanstack/react-virtual](https://tanstack.com/virtual).
Maintainers
Readme
react-glide-table
Headless React table built on @tanstack/react-table and @tanstack/react-virtual.
Primary DX is a compound API (createTable / Table.Column) with an unstyled semantic HTML shell. Customize with className, classNames (Tailwind-friendly part map), slots, and column render. useGlideTable remains the lower-level escape hatch when you need full control of markup.
The package ships no CSS — class names like DataTableJSX / data-table are opt-in hooks for your own styles.
Installation
npm install react-glide-table
# or
pnpm add react-glide-tablePeer dependencies: react and react-dom (^18 or ^19).
The package is marked "sideEffects": false for tree-shaking. Prefer subpath imports when you only need one surface:
| Import | Contents |
| ---------------------------- | -------------------------------------------------------- |
| react-glide-table | Full barrel (compound + core) |
| react-glide-table/compound | createTable / Table / DataTable + related types |
| react-glide-table/core | useGlideTable + feature hooks/helpers (no compound UI) |
Quick start (compound)
import { createTable } from "react-glide-table/compound";
import { useState } from "react";
type Product = { id: string; name: string; qty: number };
const ProductTable = createTable<Product>();
export function Products({ data }: { data: Product[] }) {
const [page, setPage] = useState(1);
return (
<ProductTable
data={data}
getRowId={(row) => row.id}
className="rounded-lg border"
classNames={{
scroll: "max-h-[480px] overflow-auto",
head: "sticky top-0 z-10 bg-neutral-50",
headCell: "px-3 text-xs font-semibold text-neutral-500",
row: "hover:bg-neutral-100 data-[selected]:bg-blue-600 data-[selected]:text-white",
cell: "border-b border-neutral-200 px-3 text-sm",
toolbar: "flex items-center justify-between gap-2 p-2",
}}
>
<ProductTable.Header>
<ProductTable.Column field="name">Name</ProductTable.Column>
<ProductTable.Column field="qty" editable>
Qty
</ProductTable.Column>
</ProductTable.Header>
<ProductTable.Pagination page={page} pageSize={10} onChange={setPage} />
</ProductTable>
);
}Customization surface
| Slot / prop | Role |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| classNames | Per-part Tailwind/utility classes (root, scroll, row, cell, toolbar, …) |
| slots.Toolbar | Top summary / actions region |
| slots.Scroll | Scroll container (must attach scrollRef to the real overflow element) |
| slots.Row | Full row replacement (cells, selection, edit UI) |
| slots.Pending / slots.Empty | Loading and empty states |
| className / column className / headerClassName | Extra class hooks |
| labels / summary / toolbar | Copy and slot nodes |
| Column.render / ColumnDef.cell | Cell content custom render (prefer update via context) |
| Column.kind / cellRenderers | Built-in or custom cell kinds (override / add via registry) |
Row/cell state is exposed as data-* attributes for Tailwind variants:
- row:
data-selected,data-hovered,data-expandable,data-expanded - cell:
data-merged,data-selection-fill,data-editable,data-editing,data-frozen,data-search-match,data-search-active, …
Example: row: "data-[selected]:bg-blue-600".
Row-level UI → slots.Row. Cell content → Column.render or Column.kind. Header/Cell are not separate slots.
slots.Scroll receives { scrollRef, className, children }. Attach scrollRef to the element that owns overflow scrolling — virtualization depends on it. Styling-only changes can stay on classNames.scroll.
Cell render context (Column.render)
Breaking change (v2.0.0): render receives one context object (not positional args). Destructure only what you need.
<ProductTable.Column
field="status"
render={({ value, update }) => (
<button type="button" onClick={() => update("done")}>
{String(value)}
</button>
)}
/>update commits through onCellChange (preferred) or onDataChange. Prefer update over calling setData inside the render.
The same update is available on TanStack ColumnDef.cell when using DataTable directly (or useGlideTable().getCellContext with flexRender). Bare cell.getContext() does not include update at runtime — only the wrapped context does (CellContextWithUpdate).
const columns: ColumnDef<Product, unknown>[] = [
{
accessorKey: "status",
header: "Status",
cell: ({ getValue, update }) => (
<button type="button" onClick={() => update?.("done")}>
{String(getValue())}
</button>
),
},
];
<DataTable data={data} columns={columns} onCellChange={...} />Double-click inline editing (editable / editType) stays separate: use it for overlay text/number edits; use render / cell + update for always-visible controls.
Cell kinds (Column.kind + cellRenderers)
Built-ins (DOM, inspired by Glide All Cell Kinds):
text, number, boolean, uri, image, bubble, markdown, drilldown, loading, protected, row-id.
Priority: column render > registry kind > default text.
const buttonRenderer = {
kind: "my-button",
render: ({ value, update }) => (
<button type="button" onClick={() => update("done")}>
{String(value)}
</button>
),
};
<ProductTable cellRenderers={[buttonRenderer]}>
<ProductTable.Header>
<ProductTable.Column field="active" kind="boolean">
Active
</ProductTable.Column>
<ProductTable.Column field="action" kind="my-button">
Action
</ProductTable.Column>
</ProductTable.Header>
</ProductTable>Same kind in cellRenderers overrides the builtin. Extra kinds are added by key.
Escape hatch (useGlideTable)
import { flexRender } from "@tanstack/react-table";
import { useGlideTable } from "react-glide-table/core";
import type { ColumnDef } from "react-glide-table/core";
type Product = { id: string; name: string; qty: number };
const columns: ColumnDef<Product, unknown>[] = [
{ accessorKey: "name", header: "Name" },
{
accessorKey: "qty",
header: "Qty",
cell: ({ getValue, update }) => (
<button type="button" onClick={() => update?.(Number(getValue()) + 1)}>
{String(getValue())}
</button>
),
},
];
export function ProductTable({ data }: { data: Product[] }) {
const { table, rows, scrollRef, getCellContext } = useGlideTable({
data,
columns,
getRowId: (row) => row.id,
rowSelectionMode: "multi",
onCellChange: (rowId, columnId, value) => {
/* update your data */
},
});
return (
<div ref={scrollRef}>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, getCellContext(cell))}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}Wire rowContextValue into your own row/cell components for edit, selection, expand, and row-span behavior. Use getCellContext(cell) (not bare cell.getContext()) so ColumnDef.cell receives update.
Clipboard copy & paste
Cell selection ships with clipboard shortcuts. The table parses TSV and emits structured payloads; your app applies domain conversion and updates data.
| Shortcut | Behavior |
| ---------------- | -------------------------------------------------------------------------------------- |
| Ctrl/Cmd+C | Copy the active selection (visible rows) |
| Ctrl/Cmd+Shift+C | Copy including collapsed tree descendants (enableSubtreeCopy) |
| Ctrl/Cmd+V | Paste overwrite into the selection (onRowsPaste, mode: "overwrite") |
| Ctrl/Cmd+Shift+V | Paste insert rows after the selection (mode: "insert", when enableInsertPaste) |
Subtree copy encodes relative tree depth as leading tabs in the TSV so paste can rebuild parent/child nesting via payload.depths. Depth is only inferred when the clipboard looks like subtree indentation (first row unindented, at least one later row indented). Otherwise leading empty cells are kept as real values (e.g. Excel/Sheets blank first column) and depths stay 0.
import type { RowsPastePayload } from "react-glide-table/compound";
<ProductTable
data={data}
getRowId={(row) => row.id}
enableCellSelection
onRowsPaste={(payload: RowsPastePayload) => {
// overwrite: update cells from startRow using payload.values / columnIds
// insert: create rows after payload.endRow (or payload.anchorRowId for trees)
setData((prev) => applyMyPaste(prev, payload));
}}
>
{/* columns… */}
</ProductTable>;Related props: onRowsPaste, enableInsertPaste, enableSubtreeCopy, onCopyActionsReady.
Helpers (/core): buildRowsPastePayload, parseClipboardTSV, parseClipboardTSVWithDepths, serializeSelectionToTSV, …
Column resize
Opt in with enableColumnResize. Drag the handle on the right edge of a header cell; double-click resets to the column’s default width / size.
<ProductTable
data={data}
enableColumnResize
// optional controlled sizing
// columnSizing={sizing}
// onColumnSizingChange={setSizing}
>
<ProductTable.Header>
<ProductTable.Column field="name" width={200} minWidth={80} maxWidth={480}>
Name
</ProductTable.Column>
<ProductTable.Column field="sku" resizable={false}>
SKU
</ProductTable.Column>
</ProductTable.Header>
</ProductTable>| Prop | Role |
| ---------------------------------------- | -------------------------------------------- |
| enableColumnResize | Turn on header drag resize (default false) |
| columnSizing / onColumnSizingChange | Controlled width map { [columnId]: px } |
| columnResizeMode | "onChange" (live) or "onEnd" |
| Column.width / minWidth / maxWidth | Default / clamp sizes |
| Column.resizable={false} | Disable resize for one column |
| classNames.resizeHandle | Style hook for the drag handle |
Column reorder
Opt in with enableColumnReorder. Drag a column header to a new position; a drop edge marks the insert point (demo). Movement below a small threshold still counts as a click, so sortable headers keep working. The resize handle does not start a reorder.
Leaf columns can move across ColumnGroups. Consecutive leaves that still share a group stay wrapped; interleaved leaves split that group (Glide-style). Frozen columns keep their freeze after the move.
<ProductTable
data={data}
enableColumnReorder
// optional controlled order (leaf column ids)
// columnOrder={order}
// onColumnOrderChange={setOrder}
>
<ProductTable.Header>
<ProductTable.Column field="name" width={200}>
Name
</ProductTable.Column>
<ProductTable.Column field="sku" reorderable={false}>
SKU
</ProductTable.Column>
</ProductTable.Header>
</ProductTable>| Prop | Role |
| --------------------------------------- | ------------------------------------------------- |
| enableColumnReorder | Turn on header drag reorder (default false) |
| columnOrder / onColumnOrderChange | Controlled leaf id list (string[]) |
| Column.reorderable={false} | Disable drag for one column (still a drop target) |
| data-reorderable / data-drop-edge | Drag / drop state hooks on header cells |
| classNames.dropEdge | Extra class while a drop edge is shown |
Helpers (/core): applyLeafColumnOrder, moveColumnIds, collectLeafColumnIds, useColumnReorder, …
Column freeze
Opt in with enableColumnFreeze. Mark columns with frozen — sticky insets are stacked so frozen cells never overlap, and column order is unchanged (middle columns may also freeze).
Keep vertical stickiness on the header via classNames.head (e.g. sticky top-0). Frozen header cells only stick horizontally (left / right); setting top on both thead and frozen th can detach the freeze band.
<ProductTable data={data} enableColumnFreeze>
<ProductTable.Header>
<ProductTable.Column field="name" frozen width={200}>
Name
</ProductTable.Column>
<ProductTable.Column field="sku">SKU</ProductTable.Column>
<ProductTable.Column field="qty" frozen="left">
Qty
</ProductTable.Column>
<ProductTable.Column field="status" frozen="right">
Status
</ProductTable.Column>
</ProductTable.Header>
</ProductTable>| Prop | Role |
| ---------------------------------- | --------------------------------------- |
| enableColumnFreeze | Turn on sticky freeze (default false) |
| Column.frozen / meta.frozen | true / "left" or "right" |
| data-frozen | "left" / "right" on frozen cells |
| data-freeze-edge | "left" / "right" / "both" on island boundaries |
Helpers (/core): buildColumnFreezeOffsets, getColumnFreezeEdgeAttr, getColumnFreezeStyle, resolveColumnFreezeSide.
Contiguous same-side freezes share one island (shadow only on the outer boundary). A gap between frozen columns creates separate islands, so both sides of the gap get an edge shadow.
Built-in search (find in page)
Opt in with enableInlineSearch for a Glide Data Grid–style search overlay (demo). This highlights matching cells and navigates between them — it does not filter rows.
On tree tables (toggleField), search also scans collapsed descendants. Navigating to a collapsed match expands the ancestor path so the cell becomes visible.
<ProductTable data={data} enableInlineSearch>
{/* columns… */}
</ProductTable>| Shortcut / control | Behavior | | ------------------ | -------- | | Ctrl/Cmd+F | Open (or focus) the search box | | Enter / ↓ | Next match | | Shift+Enter / ↑ | Previous match | | Escape / Ctrl+F (in box) / ✕ | Close |
| Prop | Role |
| ---- | ---- |
| enableInlineSearch | Turn on search (default false) |
| showSearch / onSearchClose | Controlled overlay visibility |
| searchValue / onSearchValueChange | Controlled query |
| searchResults / onSearchResultsChanged | Override / observe matches ([colIndex, rowIndex]) |
| data-search-match / data-search-active | Match highlight hooks on cells |
| classNames.search* | Style the overlay |
Helpers (/core): useInlineSearch, collectSearchMatchesInRange, formatSearchResultLabel, …
Public API
| Export | Path | Role |
| ------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------- |
| createTable / Table | /compound | Compound column DSL (Header / Column / Body / Pagination) |
| DataTable | /compound | Unstyled default renderer (semantic HTML + slots/props) |
| useGlideTable | /core | Headless engine escape hatch |
| useCellEdit / useCellSelection / useConvertTreeData / useInlineSearch | /core | Feature hooks |
| applyCellEdit, applyFillData, buildRowsPastePayload, buildColumnRowSpanMap, … | /core | Pure helpers |
| DEFAULT_DATA_TABLE_LABELS / resolveDataTableLabels | /core | Optional English UI copy helpers |
| Tree field defaults | /core | id / parentId / children / qty |
Root react-glide-table re-exports both surfaces. Related types: TableProps, TableColumnProps, DataTableProps, DataTableSlots, DataTableScrollSlotProps, TableCompoundComponent, ColumnDef, RowsPastePayload, PasteMode, …
Notable constraints
- Row span + virtualization: when
enableRowSpanis on, virtualization is forced off (HTML<table>+rowspancannot safely share a virtual window). - Paste is app-owned: the library does not mutate
dataon paste — handleonRowsPaste(coerce types, ids, tree shape, row-span keys). - Flat tree parent order:
useConvertTreeDataattaches each child to the nearest preceding row whose toggle key matchesparentId(duplicate keys after paste resolve this way). Flat inputs must list parents before their children; a child whose parent appears later becomes a root. Nestedchildrenarrays are flattened parent-before-child automatically. - No shipped CSS: the default renderer emits class hooks only. Bring your own styles (see playground for a CSS-skinned example).
Local playground
pnpm install
pnpm devThe playground uses createTable with a local CSS skin (src/styles/index.css) to exercise the compound API and headless core — including cell copy/paste (overwrite + insert) on flat and tree (BOM) tables.
License
MIT
