npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

flexi-datagrid

v1.2.8

Published

A powerful universal datagrid with tree-grouping, column pinning, and drag-and-drop.

Downloads

834

Readme

Flexi-Datagrid

A modern, highly customizable, and feature-rich React data grid component designed for speed and flexibility.

Features

  • Column Pinning (Left & Right): Pin important columns to either the left or right edge of the grid. Clicking the pin icon cycles a column through unpinned → pinned left → pinned right.
  • Pinned Bottom Rows (AG Grid-style): Pass pinnedBottomRowData to render summary/total rows in a section fixed to the bottom of the grid, independent of your main data — no per-row pin buttons required.
  • Sticky Header: The header row stays fixed in place while the body scrolls vertically — give the grid a bounded height via containerStyle to enable vertical scrolling.
  • Table Borders: Optional full grid borders via the bordered prop for a classic spreadsheet look.
  • Multi-State Sorting: Clickable column headers that support Ascending (asc), Descending (desc), and Unsorted states.
  • Built-in Filtering: Filter rows instantly with a clean popup search and multi-select dropdown for unique column values. The popup renders through a portal so it always floats above your grid content instead of being clipped by the table's scroll container.
  • Row Grouping (Drag-and-Drop): Drag column headers into the grouping zone to group your dataset hierarchically with expand/collapse support.
  • Column Reordering (Drag-and-Drop): Drag any header and drop it on another to reorder columns, AG Grid-style. Column groups move as a unit; columns can be reordered within their own group or at the top level.
  • Column Resizing: Drag the handle on the right edge of a header to resize a column live. Supports per-column minWidth/maxWidth, opt-out via resizable: false, and an onColumnResize callback.
  • Nested/Grouped Headers: Columns can be nested via children to render multi-level header rows.
  • Sticky Actions: Optional custom row actions column locked to the right side.
  • Custom Cell Rendering: Full control over how each cell is rendered via a render function.
  • Conditional Row Styling (AG Grid-style rowClassRules): Map class names to predicates evaluated per row to highlight/style rows based on their data.
  • Row Click Events: Pass onRowClick to react to clicks anywhere on a data row.
  • Row Spanning / Cell Merging: Pass rowSpanColumns (e.g. ['city', 'department']) to vertically merge consecutive rows that share the same value, hierarchically — like a spreadsheet merge.
  • Virtualized Rows for Large Datasets: Only the rows in (and just around) the visible viewport are mounted, so grids with thousands of rows stay smooth to scroll and filter.
  • Display Settings Menu: Opt-in toolbar (controlPanel) with a single Settings menu button that opens a dropdown for all display configuration — toggling pin/filter icon hover visibility, showing/hiding individual columns, and resetting to defaults — with defaultDisplayPinnedIcon/defaultHiddenColumns to configure the starting display without the panel.
  • Pagination: Opt-in (pagination) page-based navigation with a configurable page size, a rows-per-page selector, and first/previous/next/last controls, as an alternative to (or alongside) virtualized infinite scrolling.
  • Full TypeScript Support: Strongly typed configurations for robust developer experience.

Installation

Install the package via npm:

npm install flexi-datagrid

react, react-dom, and lucide-react are peer dependencies — make sure they're installed in your project.

Don't forget to import the required CSS file in your main entry file (e.g. main.tsx or App.tsx):

import 'flexi-datagrid/dist/flexi-datagrid.css';

Quick Start

import { DataTable, type ColumnConfig } from 'flexi-datagrid';
import 'flexi-datagrid/dist/flexi-datagrid.css';

interface Employee {
  id: number;
  name: string;
  department: string;
  role: string;
  salary: number;
}

const data: Employee[] = [
  { id: 1, name: 'Ava Thompson', department: 'Engineering', role: 'Frontend Developer', salary: 95000 },
  { id: 2, name: 'Liam Carter', department: 'Engineering', role: 'Backend Developer', salary: 102000 },
  { id: 3, name: 'Sofia Ramirez', department: 'Design', role: 'Product Designer', salary: 88000 },
  { id: 4, name: 'Noah Patel', department: 'Sales', role: 'Account Executive', salary: 76000 },
];

const columns: ColumnConfig<Employee>[] = [
  { key: 'name', header: 'Name', sortable: true, filterable: true, pinned: 'left', minWidth: 160, maxWidth: 500,
    style: { width: "400px", backgroundColor: "#f9f9f9" },
    headerStyle: { fontWeight: "bold", color: "#333" } },
  { key: 'department', header: 'Department', sortable: true, filterable: true },
  { key: 'role', header: 'Role', sortable: true, filterable: true, resizable: false },
  {
    key: 'salary',
    header: 'Salary',
    sortable: true,
    pinned: 'right',
    render: (value) => `$${value.toLocaleString()}`,
  },
];

export default function EmployeeTable() {
  return (
    <DataTable<Employee>
      columns={columns}
      data={data}
      rowKey="id"
      groupBy
      defaultGroupBy={['department']}
      bordered
      pinnedBottomRowData={[
        { id: -1, name: '', department: '', role: 'Total', salary: data.reduce((sum, r) => sum + r.salary, 0) },
      ]}
      containerStyle={{ maxHeight: 480 }}
      rowHeight={48}
      overscan={8}
      onColumnResize={(key, width) => console.log('resized', key, width)}
      onColumnReorder={(orderedKeys) => console.log('reordered', orderedKeys)}
      rowClassRules={{
        'row-high-earner': (record) => record.salary > 100000,
      }}
      onRowClick={(record) => console.log('clicked', record.id)}
      rowSpanColumns={['department']}
      controlPanel
      actions={(record) => (
        <button onClick={(e) => { e.stopPropagation(); console.log('Edit', record.id); }}>Edit</button>
      )}
    />
  );
}

name demonstrates a constrained resize range (minWidth/maxWidth), role opts out of resizing entirely (resizable: false), and dragging any header reorders columns — onColumnResize/onColumnReorder let you persist the user's layout. rowClassRules highlights any row with salary > 100000, and onRowClick logs the clicked row (the Edit button calls stopPropagation so clicking it doesn't also fire the row click). containerStyle's bounded maxHeight (paired with rowHeight/overscan) is also what enables virtualized scrolling for large data arrays — see Performance with Large Datasets.


How It Works

DataTable is a single, self-contained component: pass it columns and data, and it manages sorting, filtering, pinning, and grouping state internally.

Columns (ColumnConfig<T>)

Each entry in columns describes one column (or a group of columns, via children):

| Prop | Type | Description | | --- | --- | --- | | key | string | Unique identifier for the column. Used to read values off each row (record[key]) unless getValue is provided. | | header | string | Text shown in the column header. | | className / headerClassName | string | Extra classes applied to body cells / the header cell. | | style / headerStyle | CSSProperties | Inline styles for body cells / the header cell (e.g. fixed width). | | render | (value, record) => ReactNode | Custom cell renderer. Falls back to the raw value if omitted. | | getValue | (record) => any | Custom accessor used for sorting, filtering, and grouping when the value isn't a simple record[key] lookup. | | filterable | boolean | Shows a filter icon in the header that opens a multi-select popup of unique column values. | | sortable | boolean | Makes the header clickable, cycling through ascending → descending → unsorted. | | pinned | boolean \| 'left' \| 'right' | Pins the column to an edge by default (true is shorthand for 'left'). Still toggleable per-column from the UI. | | pinnedRender | (value, record) => ReactNode | Overrides render specifically for rows supplied via pinnedBottomRowData (e.g. render a "Total" label or bold sum). Falls back to render when omitted. | | children | ColumnConfig<T>[] | Nest columns to render a grouped, multi-row header. Only leaf columns are sortable/filterable/pinnable/resizable. | | resizable | boolean | Set to false to hide the resize handle and lock the column's width. Defaults to true for leaf columns. | | minWidth / maxWidth | number | Clamps how far a column can be resized, in pixels. |

Grid props (DataTableProps<T>)

| Prop | Type | Description | | --- | --- | --- | | columns | ColumnConfig<T>[] | Column definitions (see above). | | data | T[] | Row data to render. | | rowKey | keyof T \| (record: T) => string | Field name or function used to derive a unique React key per row. | | actions | (record: T) => ReactNode | Renders a sticky, right-aligned actions cell per row (e.g. edit/delete buttons). | | isLoading | boolean | Shows a loading state in place of rows. | | groupBy | boolean | Enables drag-and-drop row grouping — drag a column header into the grouping zone above the grid. | | defaultGroupBy | string[] | Column keys the grid is grouped by on initial render (requires groupBy). | | onPinChange | (columnKey, isPinned, side?) => void | Called whenever a column is pinned/unpinned, with the side ('left' | 'right') it was pinned to. | | onGroupChange | (groupedKeys: string[]) => void | Called whenever the set of grouped columns changes. | | containerStyle | CSSProperties | Inline styles for the scrollable table container. Set a maxHeight here to enable vertical scrolling with a sticky header and sticky bottom-pinned rows. | | tableStyle | CSSProperties | Inline styles for the <table> element itself. | | bordered | boolean | Draws full grid borders (all four sides of every cell) instead of the default minimal row/column dividers. | | pinnedBottomRowData | T[] | Rows rendered in a section fixed to the bottom of the grid (e.g. totals/summary rows). Independent of data — not affected by sorting, filtering, or grouping. | | pinnedRowClassName | string \| (record: T, index: number) => string | Extra class name(s) applied to each pinned-bottom row, for styling a totals/summary template. | | rowHeight | number | Row height in pixels used for virtualized row rendering. Defaults to 48, matching the built-in CSS row height — change both together if you customize row height. | | overscan | number | Number of extra off-screen rows rendered above/below the viewport, to smooth fast scrolling. Defaults to 8. | | onColumnResize | (columnKey: string, width: number) => void | Called once a column resize drag ends, with the column's new width in pixels. | | onColumnReorder | (orderedLeafKeys: string[]) => void | Called whenever columns are reordered via drag-and-drop, with the full leaf column order. | | rowClassRules | Record<string, (record: T, index: number) => boolean> | AG Grid-style conditional row classes: maps a class name to a predicate. Every rule whose predicate returns true for a row is applied to that row's <tr>. Does not apply to pinnedBottomRowData rows (use pinnedRowClassName for those). | | onRowClick | (record: T, index: number, event: React.MouseEvent) => void | Called when a data row is clicked, with the row's record, its index in the currently rendered (filtered/sorted/grouped) rows, and the click event. Adds a pointer cursor to rows when set. | | rowSpanColumns | string[] | Column keys, in priority order, to vertically merge across consecutive rows sharing the same value — e.g. ['city', 'department']. See Row Spanning. | | controlPanel | boolean | Renders an opt-in toolbar above the grid with a Settings menu button for end-user display configuration (pin/filter icon hover visibility, column show/hide). Defaults to false — existing grids are unaffected unless you turn it on. See Control Panel. | | defaultDisplayPinnedIcon | boolean | Initial value of the flag that controls whether the pin and filter icons in leaf column headers reveal on hover at all. When false, neither icon is shown on hover, regardless of controlPanel. Defaults to true (matches prior behavior). | | defaultHiddenColumns | string[] | Leaf column keys hidden by default, independent of controlPanel. Defaults to []. | | onColumnVisibilityChange | (hiddenKeys: string[]) => void | Called whenever the set of hidden columns changes via the control panel, with the full list of currently hidden leaf column keys. | | pagination | boolean | Enables page-based navigation, rendering a pagination panel below the grid instead of relying solely on scrolling. Defaults to false. See Pagination. | | defaultPageSize | number | Initial number of rows per page when pagination is enabled. Defaults to 10. | | pageSizeOptions | number[] | Options shown in the rows-per-page selector. Defaults to [10, 25, 50, 100]. | | onPageChange | (page: number, pageSize: number) => void | Called whenever the current page or page size changes. |

Filtering

Any column with filterable: true gets a filter icon in its header. Clicking it opens a searchable, multi-select popup listing the column's unique values (derived from data, via getValue if provided). Selected values are combined with AND across columns and OR within a column's own selected values. The popup is rendered through a portal anchored to the grid's own root element, so it overlays the grid instead of being clipped by the table's horizontal scroll container — similar to an Excel-style filter panel.

Sorting

Clicking a sortable column header cycles through ascending → descending → unsorted. Only one column can be actively sorted at a time.

Conditional Row Styling (rowClassRules)

rowClassRules mirrors AG Grid's API: an object mapping a CSS class name to a predicate function (record, index) => boolean. On every render, each rule is evaluated against each row, and every class whose predicate returns true is applied to that row's <tr> (multiple rules can match the same row at once). index is the row's position within the currently filtered/sorted/grouped rows, not the original data array.

const rowClassRules: Record<string, (record: Employee, index: number) => boolean> = {
  'row-high-earner': (record) => record.salary > 100000,
  'row-even': (_record, index) => index % 2 === 0,
};

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  rowClassRules={rowClassRules}
/>
.row-high-earner { background-color: #fef3c7; }
.row-even { background-color: #f8fafc; }

Rules only apply to regular data rows — group header rows and pinnedBottomRowData rows are unaffected (style the latter with pinnedRowClassName).

Row Click Events

Pass onRowClick to be notified when a data row is clicked; the grid adds a pointer cursor to rows automatically once it's set. The handler receives the row's record, its index within the currently rendered rows, and the underlying MouseEvent — call event.stopPropagation() inside interactive cell content (e.g. an actions button) if you don't want that click to also bubble up as a row click.

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  onRowClick={(record, index, event) => console.log('clicked row', index, record)}
/>

Row Spanning / Cell Merging

Pass rowSpanColumns — an ordered array of column keys — to merge a column's cell vertically across consecutive rows that share the same value, similar to a merged-cell spreadsheet. The order is a priority/hierarchy: a later key only continues merging while every key before it also matches on that run of rows. For example, with rowSpanColumns={['city', 'department']}, city merges across every consecutive row with the same city, and department merges only across consecutive rows that share both the same city and the same department — so department merges nest inside city merges rather than spanning across a city boundary.

const data: Employee[] = [
  { id: 1, city: 'Austin', department: 'Engineering', name: 'Ava Thompson' },
  { id: 2, city: 'Austin', department: 'Engineering', name: 'Liam Carter' },
  { id: 3, city: 'Austin', department: 'Design', name: 'Sofia Ramirez' },
  { id: 4, city: 'Denver', department: 'Sales', name: 'Noah Patel' },
];

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  rowSpanColumns={['city', 'department']}
/>

Here city spans 3 rows (Austin) then 1 row (Denver); department spans 2 rows (Engineering, within Austin) then 1 row each for Design and Sales.

Notes:

  • Rows must already be sorted or grouped so that equal values for a spanned column are contiguous — spanning does not itself sort or reorder rows. Combine with sortable columns, defaultGroupBy, or pre-sorted data as needed.
  • Values are compared with the column's getValue (if provided) or record[key], same as sorting/filtering.
  • Spanning only merges the cells of plain data rows; group header rows (from groupBy) always break a run, and pinnedBottomRowData rows are never merged.
  • Because rows are virtualized, spans are computed within the currently rendered window rather than across the entire dataset — a run that starts above the visible viewport will restart at the top of the window instead of continuing an off-screen merge. In practice this is invisible during normal scrolling since the affected rows aren't on screen at the same time.

Control Panel

Pass controlPanel to render a small toolbar above the grid (and above the grouping drop zone, if groupBy is also on) with a single Settings menu button. Clicking it opens a dropdown with every display option in one place, instead of a row of separate buttons:

  • Show pin/filter icons on hover — toggles displayPinnedIcon. When off, neither the pin button nor the filter button reveals on hovering a column header, regardless of whether the column is filterable; when on, hovering a leaf column header's action area reveals its filter icon (if filterable) and pin icon.
  • Columns — a checklist of every leaf column (by header label) to show or hide it. Hiding a column removes it from both the header and body, and adjusts any parent group header's colSpan automatically.
  • Reset to defaults — restores pin/filter icon hover visibility and hidden columns to the values passed via defaultDisplayPinnedIcon / defaultHiddenColumns (not to "all columns visible" — it resets to your configured defaults, so a column you deliberately hid by default stays hidden after reset).
<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  controlPanel
  defaultHiddenColumns={['role']}
  onColumnVisibilityChange={(hiddenKeys) => console.log('hidden columns', hiddenKeys)}
/>

controlPanel is opt-in and defaults to false, so existing grids render exactly as before unless you enable it. defaultDisplayPinnedIcon and defaultHiddenColumns are independent of controlPanel — you can use either to fix a display configuration (e.g. permanently hide a column, or suppress pin/filter icons everywhere) without showing the panel UI at all.

Pagination

Pass pagination to render a pagination panel below the grid: a rows-per-page selector (pageSizeOptions, defaulting to [10, 25, 50, 100]), the current range/total (e.g. "1–10 of 42"), and first/previous/next/last page controls.

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  pagination
  defaultPageSize={25}
  pageSizeOptions={[25, 50, 100]}
  onPageChange={(page, pageSize) => console.log('page', page, 'size', pageSize)}
/>

Pagination is applied after filtering and sorting, and before row grouping — so with groupBy also enabled, each page groups only the rows that landed on it. Changing a filter, the sort, or the page size automatically resets you to page 1. pagination is independent of virtualization (rowHeight/overscan): you can use either or both, though with a small pageSize a bounded, scrollable containerStyle is rarely necessary since a page's rows will usually fit without scrolling.

Row Grouping

Set groupBy to enable a drop zone above the table. Dragging a sortable/leaf column header into it groups rows by that column's value, with collapsible group rows and per-group row counts. Multiple columns can be stacked to create nested groups; use defaultGroupBy to pre-group on mount.

Column Reordering

Drag any header cell and drop it on another to reorder columns, similar to AG Grid. Dragging a leaf column reorders it among its siblings; dragging a grouped-header cell moves the whole group. A column can only be dropped among its own siblings — you can't drag a column out of one group and into another. Pass onColumnReorder to be notified of the resulting leaf column order (e.g. to persist a user's layout).

Reordering composes with pinning: pinned-left and pinned-right columns still sort to their respective edges regardless of drag order.

Column Resizing

Hover the right edge of a resizable header to reveal a resize handle; drag it to resize the column live. All leaf columns are resizable by default — set resizable: false on a ColumnConfig to disable it for that column, and use minWidth / maxWidth to constrain how far it can be dragged. Pass onColumnResize to persist widths (e.g. to localStorage) between sessions.

const columns: ColumnConfig<Employee>[] = [
  { key: 'name', header: 'Name', minWidth: 120, maxWidth: 400 },
  { key: 'role', header: 'Role', resizable: false }, // fixed width, no handle
];

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  onColumnResize={(key, width) => console.log('resized', key, width)}
/>

Column Pinning

Hovering a header reveals a pin icon. Clicking it cycles the column through unpinned → pinned left → pinned right → unpinned, locking it to the corresponding edge with a sticky position and shadow so it stays visible while scrolling horizontally. Columns marked pinned: true (or pinned: 'left') start pinned left; pinned: 'right' starts pinned right.

Pinned Bottom Rows

Pass pinnedBottomRowData — a separate array of rows (e.g. one or more totals rows) — and they're rendered in a section sticky-pinned to the bottom of the scrollable body, always visible while the main rows scroll past. This mirrors AG Grid's pinnedBottomRowData: it's plain data, not part of data/sorting/filtering/grouping, and there's no per-row pin button to manage.

Use pinnedRowClassName (a string, or a (record, index) => string function) to style the template — e.g. bold text or a tinted background for a totals row — and pinnedRender on a ColumnConfig to override how a specific column renders for these rows only (falling back to render otherwise):

const columns: ColumnConfig<Employee>[] = [
  { key: 'name', header: 'Name', pinnedRender: () => <strong>Total</strong> },
  {
    key: 'salary',
    header: 'Salary',
    render: (value) => `$${value.toLocaleString()}`,
    pinnedRender: (value) => <strong>${value.toLocaleString()}</strong>,
  },
];

const totalsRow = {
  name: '',
  salary: data.reduce((sum, row) => sum + row.salary, 0),
};

<DataTable
  columns={columns}
  data={data}
  rowKey="id"
  pinnedBottomRowData={[totalsRow]}
  pinnedRowClassName="my-totals-row"
/>

Many Columns

Header labels truncate with an ellipsis instead of overflowing into neighboring headers once a column becomes narrow (e.g. many columns, or an explicit small style.width) — no configuration needed.

Sticky Header

The header row is always fixed in place (position: sticky; top: 0) relative to the table's scroll container. To see it in action, give the grid a bounded height — e.g. containerStyle={{ maxHeight: 480 }} — so the body scrolls vertically while the header (and any bottom-pinned rows) stay put.

Table Borders

Pass bordered to draw full borders around every cell instead of the default minimal row/column dividers, for a classic spreadsheet look.

Performance with Large Datasets

Row rendering is virtualized: only the rows visible inside the scrollable container (plus a small overscan buffer) are ever mounted as actual <tr> elements, regardless of how many rows are in data. This keeps scrolling, sorting, and filtering smooth for grids with thousands of rows.

Virtualization requires a bounded, scrollable container and a known row height:

  • Give the grid a maxHeight via containerStyle so flexi-table-wrap actually scrolls (see Sticky Header) — without a bounded height, the browser page itself scrolls and every row has to be rendered.
  • The default row height is 48px, matching the built-in CSS. If you override row height via custom CSS, pass the same value through the rowHeight prop so the virtualizer's scroll math stays accurate — group header rows (from groupBy) are also sized to rowHeight internally for the same reason, so a custom rowHeight no longer causes drift (previously visible as blank gaps or an inability to fully reach the last rows once scrolled to the bottom).
  • Tune overscan (default 8) if you see blank flashes on very fast scrolling — a higher value trades a bit of extra rendering for smoother scroll.

Filtering is also optimized for large datasets: a filter column's unique values are computed once per column (not on every keystroke), and column lookups used during filtering/sorting/grouping are indexed by key instead of scanned linearly.


Full Example (Every Feature)

A single grid wiring up every prop at once — nested/grouped headers, pinning, resizing, reordering, filtering, sorting, row grouping, row spanning, the control panel, pagination, pinned bottom totals, conditional row styling, row clicks, and virtualization:

import { DataTable, type ColumnConfig } from 'flexi-datagrid';
import 'flexi-datagrid/dist/flexi-datagrid.css';

interface Employee {
  id: number;
  city: string;
  department: string;
  name: string;
  role: string;
  baseSalary: number;
  bonus: number;
}

const data: Employee[] = [
  { id: 1, city: 'Austin', department: 'Engineering', name: 'Ava Thompson', role: 'Frontend Developer', baseSalary: 95000, bonus: 8000 },
  { id: 2, city: 'Austin', department: 'Engineering', name: 'Liam Carter', role: 'Backend Developer', baseSalary: 102000, bonus: 9500 },
  { id: 3, city: 'Austin', department: 'Design', name: 'Sofia Ramirez', role: 'Product Designer', baseSalary: 88000, bonus: 4000 },
  { id: 4, city: 'Denver', department: 'Sales', name: 'Noah Patel', role: 'Account Executive', baseSalary: 76000, bonus: 12000 },
];

const columns: ColumnConfig<Employee>[] = [
  { key: 'city', header: 'City', sortable: true, filterable: true, minWidth: 100, maxWidth: 240 },
  { key: 'department', header: 'Department', sortable: true, filterable: true, minWidth: 120 },
  {
    key: 'name',
    header: 'Name',
    sortable: true,
    filterable: true,
    pinned: 'left',
    minWidth: 160,
    maxWidth: 500,
    style: { width: '220px' },
  },
  { key: 'role', header: 'Role', sortable: true, filterable: true, resizable: false },
  {
    key: 'compensation',
    header: 'Compensation',
    children: [
      {
        key: 'baseSalary',
        header: 'Base Salary',
        sortable: true,
        pinned: 'right',
        render: (value) => `$${value.toLocaleString()}`,
        pinnedRender: () => <strong>Total</strong>,
      },
      {
        key: 'bonus',
        header: 'Bonus',
        sortable: true,
        render: (value) => `$${value.toLocaleString()}`,
        pinnedRender: (value) => <strong>${value.toLocaleString()}</strong>,
      },
    ],
  },
];

export default function EmployeeTable() {
  return (
    <DataTable<Employee>
      columns={columns}
      data={data}
      rowKey="id"
      bordered
      containerStyle={{ maxHeight: 480 }}
      tableStyle={{ minWidth: 900 }}
      rowHeight={48}
      overscan={8}
      groupBy
      defaultGroupBy={[]}
      onGroupChange={(groupedKeys) => console.log('grouped by', groupedKeys)}
      rowSpanColumns={['city', 'department']}
      onColumnResize={(key, width) => console.log('resized', key, width)}
      onColumnReorder={(orderedKeys) => console.log('reordered', orderedKeys)}
      onPinChange={(key, isPinned, side) => console.log('pin change', key, isPinned, side)}
      controlPanel
      defaultDisplayPinnedIcon={true}
      defaultHiddenColumns={[]}
      onColumnVisibilityChange={(hiddenKeys) => console.log('hidden columns', hiddenKeys)}
      pagination
      defaultPageSize={25}
      pageSizeOptions={[25, 50, 100]}
      onPageChange={(page, pageSize) => console.log('page', page, 'size', pageSize)}
      pinnedBottomRowData={[
        { id: -1, city: '', department: '', name: '', role: '', baseSalary: 0, bonus: data.reduce((sum, r) => sum + r.bonus, 0) },
      ]}
      pinnedRowClassName="totals-row"
      rowClassRules={{
        'row-high-earner': (record) => record.baseSalary > 100000,
      }}
      onRowClick={(record, index) => console.log('clicked row', index, record.name)}
      actions={(record) => (
        <button onClick={(e) => { e.stopPropagation(); console.log('Edit', record.id); }}>Edit</button>
      )}
    />
  );
}

Notes on combining features in this example:

  • city/department are left as plain leaf columns (not pinned/nested) so rowSpanColumns={['city', 'department']} can merge them — data is already sorted by city then department, which the merge requires (see Row Spanning).
  • compensation demonstrates a nested/grouped header: baseSalary and bonus render under a shared "Compensation" header cell, and baseSalary is independently pinned right.
  • pinnedRender on baseSalary/bonus overrides how the totals row renders those two cells only; every other column in pinnedBottomRowData falls back to its normal render.
  • groupBy is enabled with an empty defaultGroupBy — the grid starts flat (so the row spans above are visible) but a user can still drag role, for example, into the drop zone to group by it on top of everything else.
  • controlPanel adds the Settings menu button for pin/filter icon hover visibility and column visibility toggling on top of the defaultDisplayPinnedIcon/defaultHiddenColumns starting state — omit controlPanel (or leave it false) to keep those two props as a fixed, non-interactive configuration instead.
  • pagination slices the filtered/sorted rows into pages of defaultPageSize (25 here) before grouping — combine cautiously with rowSpanColumns, since spans only merge cells within the current page's rows.

🛠 Local Development

npm install
npm run build  

📄 License

ISC