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

freeze-table

v1.2.0

Published

Virtualized React list table — frozen (pinned) columns, drag to resize/reorder/hide, per-column filters, sortable, keyboard row navigation, sticky totals footer, and a built-in column + freeze toolbar. Themeable with CSS variables, per-slot classNames and

Downloads

2,761

Readme

freeze-table

A virtualized React list table for wide, dense, data-entry style screens — the kind with twenty columns, thousands of rows, per-column search boxes, frozen leading columns and a totals row pinned to the bottom.

freeze-table — 2,000 rows, 18 columns, three columns frozen left and one plus the Action column frozen right

Built on react-table v7, which is bundled in. No peer install, no UI library, no CSS file to import and nothing to configure to get started — the handful of glyphs it needs (sort arrows, pin marker, spinner, empty-state icon) are inline SVG.

And when it does have to match your app, it goes the whole way: CSS-variable tokens (with a built-in dark theme), per-slot class names, replaceable components — hand it your own button, popover and input — or fully unstyled, keeping the freeze and the virtualization and nothing else. See §12.

npm i freeze-table

That is the whole install. react-table v7 is bundled in (see Compatibility), so React is the only peer dependency.

import { FreezeTable } from 'freeze-table';

const columns = [
  { type: 'serial', pinned: true },
  { Header: 'Customer', accessor: 'name', width: 200, pinned: true },
  { Header: 'Invoice date', accessor: 'date', type: 'date' },
  { Header: 'Amount', accessor: 'amount', type: 'currency', width: 140, footer: 'sum' },
];

<FreezeTable columns={columns} data={rows} height={560} toolbar />

That table sorts, searches per column, freezes its first two columns, totals the amount column in a sticky footer, and ships its own Columns and Freeze menus.


Why

Most grids either give you a plain HTML table that dies at 2,000 rows, or a full datagrid framework with its own theming system. This one sits in between: it does exactly what an accounting / ERP list screen needs, in a handful of files you can read — a 300-line component shell over one hook per concern.

  • Nothing to import but the component. No CSS file, no theme provider, no plugin registration. npm i freeze-table and the table above is the whole setup.
  • One scrollport, both axes. Header, body and footer are children of the same wide inner div, so horizontal scrolling moves them together — columns never drift apart.
  • Frozen columns are real position: sticky. Nothing runs in JS per scroll frame, so the frozen block does not shake or lag behind the rest of the row.
  • Rows are windowed by hand (~15 lines) rather than by a virtualization library — which is precisely what keeps the single scrollport, and therefore the sticky freeze, possible.
  • Keyboard first. ↑/↓/Home/End/Enter work on mount, without the user clicking in.
  • The user's layout is theirs. Freeze boundary, column widths, column order and hidden columns are all draggable / toggleable at runtime and survive a reload (one pinStorageKey). Even the Action column can be dragged out of the right-hand end.
  • Selection repaints imperatively, so arrow-key navigation does not re-render every visible row.
  • The config is short. type: 'currency' is the alignment, the width floor, the ellipsis cell, the title and the grouping; footer: 'sum' is the reduce and the formatting. toolbar is the column and freeze menus. Nothing here is a framework you have to adopt — every one of them is a shorthand for something you can still write out.

Table of contents

  1. Quick start
  2. Layout and scroll model
  3. Column config
  4. Props
  5. Toolbar and imperative ref API
  6. Keyboard navigation and body states
  7. Footer totals
  8. Frozen (pinned) columns
  9. Column resizing, hiding and reordering
  10. Per-row status colouring
  11. Restoring position on re-entry
  12. Theming: tokens, classes, slots, unstyled
  13. Gotchas
  14. Demo, build, compatibility
  15. What changed in 1.1
  16. What changed in 1.0

1. Quick start

import React, { useMemo, useRef } from 'react';
import { FreezeTable } from 'freeze-table';

const COLUMNS = [
  // The 1..N display-order column. No accessor, no Cell, no `disableSortBy`.
  { type: 'serial', pinned: true },

  { Header: 'Customer Name', accessor: 'name', width: 200, pinned: true, footer: 'count' },

  // `type` brings the alignment, the width floor, the ellipsis + title cell and the
  // formatting; `footer: 'sum'` brings the reduce, formatted the same way.
  { Header: 'Invoice date', accessor: 'invoice_date', type: 'datetime' },
  { Header: 'Amount', accessor: 'amount', type: 'currency', width: 140, footer: 'sum' },
];

export default function CustomerList({ byId, fetched, openRow }) {
  const tableRef = useRef(null);
  const columns = useMemo(() => COLUMNS, []);
  const data = useMemo(() => Object.values(byId), [byId]);

  return (
    <FreezeTable
      ref={tableRef}
      columns={columns}
      data={data}
      height={560}
      status={fetched ? 'ready' : 'loading'}
      toolbar                      // Columns + Freeze menus, rendered for you
      locale="en-IN"
      currencySymbol="₹"
      pinStorageKey="customer-list"
      onRowEnter={(row) => openRow(row)}
    />
  );
}

Nothing above is required except columns and data. Everything else — type, footer, toolbar, status, pinStorageKey — is a shorthand you can drop back down to its longhand (Cell, Footer, your own menus, loading + dataFetched, the ref API) the moment a screen needs something the shorthand does not cover.

Writing it the long way

The same two columns without any shorthand, which is what every version before 1.0 required — and still works exactly as it did:

{ Header: '#', id: 'sl', width: 50, minWidth: 50, align: 'right',
  disableFilters: true, disableSortBy: true,
  Cell: ({ row, rows }) => rows.indexOf(row) + 1 },

{ Header: 'Amount', accessor: 'amount', width: 140, minWidth: 140, align: 'right',
  Cell: ({ value }) => (
    <div style={ELLIPSIS}>{Number(value || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</div>
  ),
  Footer: (info) => info.rows
    .reduce((s, r) => s + Number(r.values.amount || 0), 0)
    .toLocaleString('en-IN', { minimumFractionDigits: 2 }) },

Both columns and data should be memoized — see Gotchas.

Exports

| Export | What it is | |---------------|------------------------------------------------------------------| | FreezeTable | The component (also the default export) | | CommonTable | Alias of FreezeTable, for projects migrating off a local copy | | ELLIPSIS | Shared single-line-ellipsis style object for custom Cells |

TypeScript definitions ship with the package (index.d.ts) — FreezeTableProps, FreezeTableColumn, FreezeTableHandle are all exported as types.


2. Layout and scroll model

ft-root  (position: relative, the `height` you pass)
├── ft-wrap  (overflow: auto, native bars hidden) ← THE scrollport for BOTH axes
│   └── inner div (minWidth: totalColumnsWidth, flex column, minHeight 100%)
│       ├── ft-head   (position: sticky; top: 0)     ← labels + sort + search boxes
│       ├── body      (position: relative, height = rows * rowHeight)
│       │   └── ft-row (position: absolute, top = index * rowHeight) → ft-td cells
│       └── ft-foot   (position: sticky; bottom: 0)  ← optional totals row
├── ft-track ft-track-v  (inset by the header/footer heights) ← overlay vertical bar
└── ft-track ft-track-h                                       ← overlay horizontal bar
  • height is the TOTAL height (header + body + footer), not the body height. The band left for rows is measured with a ResizeObserver, so rows fill the gap exactly — no clipped last row, no second scrollbar on the page.
  • There is exactly one vertical and one horizontal scrollbar, both on ft-wrap.
  • Only the rows in [firstIdx .. lastIdx] (viewport ± 6) are mounted; each is absolutely positioned inside a container of the full content height, so the native scrollbar still represents the whole list.
  • The header/footer stay put because they are sticky, not because anything is repositioned in JS.

Why the scrollbars are drawn, not native

ft-wrap covers the header and the footer as well as the rows, so its native vertical scrollbar ran the full height of the table — a thumb sitting beside the header reads as wrong, since the header does not scroll.

Moving that bar onto the body is not possible while the columns freeze: an element that scrolls in y is a scroll container in x too, so the body would become the sticky scrollport for the pinned cells and they would slide away with the rest of the row (this is exactly the react-window problem the component was rewritten to escape).

So the native bars are hidden and redrawn as overlays — the vertical track inset by the measured header and footer heights, so it spans the rows only. Both bars are drawn rather than just the vertical one because Firefox's scrollbar-width takes no axis, and drawing both keeps them identical across browsers. Wheel, trackpad, keyboard and touch scrolling all remain fully native; the thumbs are positioned from the same rAF-throttled handler that drives the windowing, so nothing extra re-renders. Dragging a thumb temporarily suspends row snapping, which would otherwise make the drag feel sticky.


3. Column config

{
  Header,           // string | node
  label,            // plain-text name for the Columns / Freeze menus. REQUIRED when
                    //   Header is a node — a menu entry cannot render an element, and
                    //   without it the column is listed by its field name
  accessor,         // field key, or (row) => value (an accessor fn also needs `id`)
  id,               // required when accessor is a fn or absent
  type,             // 'text' | 'number' | 'currency' | 'date' | 'datetime' | 'boolean' | 'serial'
  footer,           // 'sum' | 'avg' | 'min' | 'max' | 'count' | node | fn(info)
  format,           // (value, row) => node — a one-off formatter, no Cell needed
  decimals,         // number/currency decimal places (currency defaults to 2)
  dateFormat,       // per-column override of the table's date pattern
  booleanLabels,    // [whenTrue, whenFalse] for a boolean column
  blankZero,        // render 0 as blank (default: true for text, false for numbers)
  Cell,             // optional renderer; default prints the raw value with ellipsis + title
  width,            // px — on its own this is ALSO the minWidth
  minWidth,         // px (defaults to `width`, else 90)
  maxWidth,         // px
  align,            // 'left' | 'center' | 'right' — header, cells and footer
  disableFilters,   // hide this column's search box
  disableSortBy,    // no sorting on this column
  Footer,           // string | node | fn(tableInstance) — see §7
  noPadding,        // drop the default cell/header padding
  pinned,           // DEFAULT freeze state: true / 'left' / 'right' — see §8
  hidden,           // DEFAULT visibility: true = start hidden — see §9
  hideable,         // false = can never be hidden (a key column)
  disableResizing,  // no drag-to-resize grip on this column
  Filter,           // custom filter UI (e.g. a dropdown for an enum column)
  filter,           // custom react-table filter fn (rows, columnIds, filterValue)
}

Column types

type is a shorthand for the four or five lines a column of that kind always needed. It only fills in what you did not write — { type: 'currency', align: 'left' } is a left-aligned currency column, not an argument.

| type | Fills in | |-------------|--------------------------------------------------------------------------------------| | text | ellipsis cell with a title (the default renderer, named) | | number | right-aligned, locale grouping, decimals (default: as they come, max 3) | | currency | right-aligned, locale grouping, 2 decimals, optional currencySymbol prefix | | date | minWidth: 110, formatted with dateFormat (default 'DD-MM-YYYY') | | datetime | minWidth: 150, formatted with dateTimeFormat (default 'DD-MM-YYYY HH:mm') | | boolean | centred, / blank — override with booleanLabels: ['Yes', 'No'] | | serial | the whole 1..N display-order column: # header, 50px, right-aligned, no sort/search |

Date columns accept a Date, an epoch number, an ISO string, or the 'YYYY-MM-DD HH:mm:ss' a SQL backend hands back (Safari refuses that one until the space becomes a T, which is handled here). Anything unparseable is printed as it came, never as Invalid Date. Format tokens: YYYY YY MMM MM DD HH hh mm ss A.

serial counts through rows — the filtered and sorted set — so the numbering stays 1..N after any sort or search.

A zero renders as blank in text (and untyped) columns and as a real 0 in numeric ones. These lists are financial: a column of zeros is noise, but a formatted 0.00 in an amount column is information. blankZero overrides it either way.

Footer shorthands

footer: 'sum' computes over info.rows — the filtered rows, so the total follows the search boxes — and formats the result with the column's own formatter, so a currency total lands with the same decimals and grouping as the cells above it. 'avg', 'min', 'max' and 'count' work the same way. Non-numeric and blank values are skipped rather than turning the total into NaN.

Footer (the react-table key) still works and wins if you give both.

Widths are pixels, not flex weights

react-table computes totalWidth = min(max(minWidth, width), maxWidth), and useFlexLayout only emits a real flex-grow when column.canResize is set — which only useResizeColumns does, and this component does not use it. So every cell renders flex: 0 0 auto at totalWidth px, and nothing stretches to fill leftover space.

A width on its own sets the minWidth too, so one number means one width: { width: 45 } renders 45px. (Before 1.0 it rendered 90px — react-table's floor — and the fix was to repeat the number, which is why every older config says width: 120, minWidth: 120. Those still mean exactly what they did.)

Set both when you want a column that renders wider than its floor. A width below an explicit minWidth is still ignored (width: 2.4, minWidth: 200 → a 200px column).

A dragged width (§9) overwrites width, minWidth and maxWidth with the same number, precisely so that expression collapses to it — the config's floor and ceiling are the default, and an explicit drag outranks both.

What Cell receives

Cell gets the table instance spread, so:

  • custom props you pass to the table are readable — userList is forwarded: Cell: ({ value, userList }) => ...
  • core instance fields are available too — e.g. a display-order serial column: Cell: ({ row, rows }) => rows.indexOf(row) + 1. Using rows (the filtered + sorted set) keeps the numbering 1..N after any sort or filter; row.index alone would shuffle.

context is the general-purpose version of the same thing, and the intended way for a cell to reach your callbacks:

<FreezeTable columns={columns} data={rows} context={{ openPopup, canEdit }} />

{ Header: 'Customer', accessor: 'name',
  Cell: ({ value, row, context }) => (
    <a onClick={() => context.openPopup(row.original)}>{value}</a>
  ) }

That keeps columns a module-level constant. The older way — a factory rebuilt in the caller — still works, and is what you want when a column's structure (not just its callbacks) depends on props:

const columns = useMemo(() => makeColumns(openPopup), []);

Auto-appended columns

  • __strip — prepended when rowStripColor is given: the narrow status-bar column (stripWidth, default 14px). Unsortable, unfilterable, auto-freezes with the pinned run.
  • __actions — added when Actions is given: the Action column (minWidth: actionWidth, default 110). Renders <Actions object={row.original} fn={fn} /> — note there is no row index. It starts at the right-hand end (actionIndex), but it is a real column: the user can drag it anywhere, and it freezes with whichever frozen run it ends up inside (§8, §9).

Date columns

type: 'datetime' covers it — including the 150px floor that keeps DD-MM-YYYY HH:mm from being clipped:

{ Header: 'Created', accessor: 'created_at', type: 'datetime' }

For a pattern this column alone needs, add dateFormat; to change it for the whole table, use the dateFormat / dateTimeFormat props. For anything the tokens cannot express, format: (v) => myOwnFormatter(v) keeps the ellipsis cell and drops the parsing.


4. Props

| Prop | Default | Meaning | |---------------------|-----------------------|----------------------------------------------------------------------| | columns | (required) | Column config array. Memoize it | | data | (required) | Row array. Memoize it | | height | 500 | Total table height — a number is px, '100%' / '60vh' / 'calc(…)' / 'fill' are used as-is | | toolbar | false | Render the built-in Columns and Freeze menus (§5) | | rowHeight | 44 | Row height px (a dense list uses 35) | | fontSize | 12 | Drives cells, header labels and footer (dense: 11) | | Actions | — | Component for the auto-appended Action column | | fn | — | Passed straight through to Actions as its fn prop | | actionWidth | 110 | Action column min width px | | pinActions | false | Freeze just the Action column against the edge it sits at (§8) | | actionIndex | 'last' | Where the Action column starts'first', 'last' or an index (§9) | | userList | — | Forwarded onto the table instance → readable in every Cell | | context | — | Also forwarded — the intended way for a Cell to reach your callbacks (§3) | | locale | browser | BCP-47 locale for number / currency columns, e.g. 'en-IN' | | currencySymbol | — | Prefix for currency cells and totals | | dateFormat | 'DD-MM-YYYY' | Pattern for date columns | | dateTimeFormat | 'DD-MM-YYYY HH:mm' | Pattern for datetime columns | | status | — | 'idle' \| 'loading' \| 'ready' — one prop instead of loading + dataFetched (§6) | | sortable | true | Master switch for sorting | | searchable | true | Master switch for the per-column search boxes | | loading | false | Spinner + loadingText instead of the body — superseded by status | | dataFetched | true | Gate for the empty state — superseded by status (§6) | | emptyText | 'No records found' | Empty-state copy | | loadingText | 'Fetching records…' | Loading-state copy | | footerLeft | null | Static left-aligned footer label (prefer a column Footer — §7) | | showFooter | auto | Override footer visibility | | rowNavigation | true | Keyboard row navigation (§6) | | rowSnap | false | Settle vertical scrolling on a row boundary once it stops (§6) | | onRowSelect | — | (rowData, index) on every selection change | | onRowEnter | — | (rowData, index) on Enter — "open this row" | | selectedBg | --ft-row-selected | Selected-row highlight colour | | rowIdKey | 'id' | Field initialSelectedId matches against | | initialSelectedId | null | Re-select + scroll to this row once, after the rows load | | initialScrollLeft | 0 | Restore horizontal scroll once, after the rows load | | rowStripColor | — | (rowData) => color \| null — coloured status bar column (§10) | | rowStripTitle | — | (rowData) => string — hover tooltip on the strip cell | | rowStyle | — | (rowData) => ({ backgroundColor?, color? }) — full-row tint (§10) | | stripWidth | 14 | Strip column width px | | pinStorageKey | — | Persist the freeze boundaries, column widths, hidden columns and column order in localStorage (§8, §9) | | resizable | true | Drag-to-resize grip on every header's right edge (§9) | | reorderable | true | Drag a header sideways to move that column (§9) | | minColumnWidth | 48 | Floor for a drag-resized column, px (§9) | | onColumnResize | — | (id, width, widths)width is null on a reset (§9) | | onColumnVisibilityChange | — | (hiddenIds) whenever a column is hidden or shown (§9) | | onColumnOrderChange | — | (order) whenever the column order changes (§9) | | defaultLayout | — | Starting layout for a table with nothing stored yet (§5) | | onLayoutChange | — | (layout) whenever any part of the layout changes (§5) | | theme | — | 'light' \| 'dark' \| 'auto' — the built-in palettes (§12) | | tokens | — | Design tokens as inline custom properties, e.g. { accent: '#7c3aed' } (§12) | | classNames | — | A class per slot, merged after the built-in one (§12) | | components | — | Replace a slot outright — your button, popover, input (§12) | | unstyled | false | No paint and no injected stylesheet; the freeze still works (§12) | | styleNonce | — | nonce for the injected <style>, for a strict CSP (§12) | | styleTarget | root node | Where to inject the stylesheet — set it for a shadow root (§12) | | className | — | Extra class on the root element (applied after classNames.root) | | style | — | Extra inline styles merged onto the root element (wins over tokens) |


5. Toolbar and imperative ref API

Two ways to drive the table from outside. Start with the toolbar; drop to the ref when you want the menus to look like the rest of your app.

The built-in toolbar

<FreezeTable columns={columns} data={rows} toolbar />

That renders a strip above the header with two menus:

  • Columns — show / hide (locked columns are listed but disabled), move a column one place either way, and Show all / Reset widths / Reset order. It stays open while you toggle, so several changes are one visit.
  • Freeze — "Up to " for the left edge and "From " for the right, plus No freeze. Entries past what the viewport allows are disabled, not hidden, so the menu shows why a column cannot be frozen instead of quietly omitting it. The button carries the current counts.

Every choice hands focus back to the rows, so the arrow keys keep working without a click.

Pass an object to configure it:

toolbar={{
  columns: true,                     // show the Columns menu (default true)
  pin: false,                        // drop the Freeze menu
  left: <strong>Invoices</strong>,   // your own content, left-aligned
  right: <button onClick={refresh}>Refresh</button>,   // …and just before the menus
}}

The toolbar sits outside the scrollport, so it does not disturb the freeze — and it counts towards height, which is the whole table box.

The ref

const tableRef = useRef(null);
<FreezeTable ref={tableRef} … />

Everything the toolbar does is here too, so a menu of your own is still a first-class option — that is all the built-in one is.

| Method | Meaning | |--------------------|----------------------------------------------------------------------------| | focus() | Re-focus the table container — e.g. return focus to the selected row after a modal closes | | getScrollLeft() | Current horizontal offset — stash it before navigating away (§11) | | selectRow(i) | Select + scroll to + focus row i | | getLeftPinCount() | Current effective (viewport-capped) left boundary; 0 = none | | getMaxLeftPinCount() | Largest left boundary the viewport allows — disable menu entries beyond it | | setLeftPinCount(n) | Freeze the first N caller columns against the left edge | | getRightPinCount() | Current effective right boundary; 0 = none | | getMaxRightPinCount() | Largest right boundary the viewport allows | | setRightPinCount(n) | Freeze the last N caller columns against the right edge | | getColumnWidths() | User-resized widths only, as an id -> px map (§9) | | setColumnWidth(id, px) | Set one column's width (clamped to minColumnWidth) | | resetColumnWidths(id?) | Clear one override, or all of them when called with no argument | | getHiddenColumns() | Ids of the currently hidden columns | | setHiddenColumns(ids) | Replace the hidden set (hideable: false ids are ignored) | | toggleColumn(id, visible?) | Hide/show one column — omit visible to flip it | | showAllColumns() | Un-hide everything | | getColumnOrder() | The current order as a flat list of ids — display order, hidden columns included, '__actions' among them (§9) | | setColumnOrder(ids) | Replace the order; ids you leave out are slotted back in beside their configured neighbours. null restores the config order | | moveColumn(id, i) | Move one column to position i of getColumnOrder() | | resetColumnOrder() | Back to the order of your columns array | | getColumnList() | [{ id, index, position, header, hidden, hideable, resizable, movable, width }] — everything a column menu needs, in display order, Action column included | | setColumnWidths(map) | Replace the whole id -> px map at once | | getLayout() | The whole layout as one object: { pins: { left, right }, widths, hidden, order } | | setLayout(layout) | Apply one. Every key is optional; null restores the column config | | resetLayout() | Drop every user layout choice |

Both boundaries persist when pinStorageKey is set. getPinCount / getMaxPinCount / setPinCount are the pre-0.6 names for the three left-hand methods — they still work, but nothing in those names said which edge they meant, hence the rename.

Saving a layout per user

pinStorageKey persists the layout in localStorage, which is per browser. getLayout() is the same state as a plain object, so it can be stored per user instead — saved views, a layout that follows someone between machines, an "apply this preset" button:

<FreezeTable
  ref={tableRef}
  defaultLayout={savedView}                       // what the server had
  onLayoutChange={(layout) => saveView(layout)}   // any change: pins, widths, hidden, order
/>

onLayoutChange is the one callback for all four choices — the three onColumn* callbacks cover only their own slice, and the freeze boundaries have none of their own. Priority when a table mounts: localStorage (if pinStorageKey is set) → defaultLayout → the column config. Freeze counts come back uncapped, so a boundary saved on a wide screen is not permanently trimmed by the window it was read from.

selectRow(0) is the one you reach for on a list that re-fetches on a Search click: the table is already mounted, so the mount-time focus effect will not fire again and focus would otherwise stay on the Search button, leaving the arrow keys dead until the user clicks the table.


6. Keyboard navigation and body states

  • The first render selects and highlights row 0 and focuses the table, so the arrows work immediately.
  • ↑ / ↓ move the selection (auto-scrolling it into view), Home / End jump to the first / last row, click selects, Enter fires onRowEnter.
  • Typing inside an INPUT / TEXTAREA / SELECT is never hijacked, so the per-column search boxes behave normally.
  • The selection highlight is painted imperatively: rows are a React.memo that reads the selected index from a ref, and an effect updates only the affected DOM nodes. Without this, every keypress re-rendered every visible row — visibly laggy on icon-heavy lists.
  • Selection is index-based: after a sort the highlight stays at the same position (a different logical row). Track ids yourself via onRowSelect if you need otherwise.
  • Turn the whole thing off with rowNavigation={false}.

Body states

status is one prop with three values:

| status | Body | |-------------|-------------------------------------------------------------| | 'loading' | the spinner and loadingText | | 'ready' | the rows — or emptyText when there are none | | 'idle' | neither: nothing has been asked for yet |

The 'idle' state is the one worth knowing about: before the first fetch there are no rows and nothing is loading, and "No records found" over a list nobody has searched yet is a lie. That is what the old loading + dataFetched pair encoded between them — two booleans that had to be wired together correctly, in the right order, on every screen. Both still work, and status wins when you pass it.

Row-boundary scroll snapping (rowSnap, off by default)

Because ft-wrap is the single scrollport and the header is sticky inside it, rows scroll under the header, so a freely-scrolled list leaves a partial row along the header's bottom edge. rowSnap settles the scroll on a row boundary instead, the way a spreadsheet does, so the topmost row is always whole.

It is off by default, because snapping and continuous scrolling genuinely trade against each other: every wheel notch gets re-settled onto a row, and that reads as the list catching and stuttering rather than gliding. Free scrolling feels better to most people than a guaranteed whole top row; turn it on if your screen is dense enough for the partial row to matter.

When enabled it is CSS — scroll-snap-type: y proximity plus scroll-snap-align: start on each row, with scroll-padding-top set to the measured header height so a snapped row lands just below the header rather than at the hidden top of the scrollport. Snapping is suspended while the wheel is turning and restored ~160 ms after it stops, so the scroll glides and settles once at the end instead of catching on every notch. Dragging a scrollbar thumb suspends it too. The horizontal axis is never snapped.

proximity, not mandatory: rows are windowed, so snap targets appear and disappear as you scroll, and mandatory snapping fights both that and programmatic scrolls.

Body states, in order: loading → spinner; rows.length === 0 && dataFetched → the soft empty state; otherwise the list.

Gotcha: dataFetched defaults to true and loading to false, so passing neither flashes "No records found" before the first fetch ever returns. Wire both from your own fetch flag:

<FreezeTable loading={!fetched} dataFetched={fetched} … />

7. Footer totals

Any column can define Footer (string, node, or a function). A footer function receives the table instance, so info.rows are the filtered rows — totals and counts update live as the user types in a column search box:

Footer: (info) => `Voucher Count : ${info.rows.length}`

Footer: (info) => info.rows
  .reduce((s, r) => s + Number(r.values.debit || 0), 0)
  .toLocaleString('en-IN', { minimumFractionDigits: 2 })

Use align: 'right' on amount columns so the total lines up under the values.

footerLeft renders a single left-aligned label, but it is a static overlay outside react-table — it cannot see the filtered rows. For a live record count, use a Footer function on the first data column instead.

The footer appears automatically when any column has a Footer or footerLeft is set; override with showFooter.


8. Frozen (pinned) columns

Columns freeze against either edge, and each side is described by a single number:

| Side | Column config | The count | Rides along | |---|---|---|---| | Left | pinned: true / pinned: 'left' | pinCount — how many leading columns | the status strip | | Right | pinned: 'right' | rightPinCount — how many trailing columns | the Action column |

Freezing only makes sense as a run against its own edge — a frozen column in the middle would have its neighbours scroll out from under it — so only a leading run counts on the left and only a trailing run on the right. A flag after the first unflagged column (or, on the right, before the last one) is ignored.

Both counts speak in terms of the columns currently on screen: a hidden column (§9) is not counted, so "first 3" with the second column hidden freezes the 1st, 3rd and 4th of your columns array.

  • The status strip freezes left whenever pinCount > 0 — it sits left of everything and would otherwise be stranded outside the block.

  • The Action column freezes with whichever run it lies inside. Left at its default position that means the right-hand one, exactly as before; drag it (§9) in front of a left-frozen run and it freezes there instead; park it in the middle of the scrolling columns and it cannot freeze at all, because a frozen run has to stay contiguous.

  • To freeze only the row's actions, pass pinActions and leave rightPinCount at 0. Keeping the row controls reachable without scrolling back is the commonest reason to want anything frozen on the right, and it should not force a data column along with it (this too follows the column wherever it sits, and is ignored in the middle):

    <FreezeTable columns={columns} data={rows} Actions={RowActions} pinActions />
  • The user changes the boundaries at runtime through setLeftPinCount(n) and setRightPinCount(n) on the ref (0 = no freezing on that side). With pinStorageKey set, both choices persist — localStorage["ctPin:<key>"] and ["ctPinR:<key>"] — and beat the config flags on the next mount.

  • Each boundary column shows a small blue pin in its header: the last frozen column on the left, the first on the right. That is the only indicator on purpose — an icon on every frozen column ate header width and truncated the labels.

  • The left block casts a shadow to its right once the table is scrolled; the right block casts one to its left until the scroll reaches the end.

Viewport cap. The frozen blocks are hard-capped so at least 250px of viewport is always left for the scrolling columns (getMaxLeftPinCount() / getMaxRightPinCount()). The right block is measured first and the left one funded from what remains, so the two caps cannot both claim the same viewport. A stored-but-too-large boundary is clamped at render, so a persisted over-wide choice self-corrects. Freezing wider than the viewport would leave no room to actually read the scrolling columns.

The freeze menu

toolbar renders one (§5). If you want it in your own design system, the component's state is fully addressable through the ref: list No pin plus every column ("pin up to here" semantics), read getLeftPinCount() when the menu opens, and disable entries past getMaxLeftPinCount(). The right-hand block works the same way through getRightPinCount() / setRightPinCount():

const openPinMenu = () => {
  setCurrent(tableRef.current.getLeftPinCount());
  setMax(tableRef.current.getMaxLeftPinCount());
  setOpen(true);
};

const pickPin = (n) => {
  tableRef.current.setLeftPinCount(n);
  tableRef.current.focus();   // hand the arrow keys back to the rows
  setOpen(false);
};

If your list also has single-letter keyboard shortcuts, include the menu-open state in their guard so they do not fire while the menu is up.

How the freeze works

Plain CSS position: sticky on the header, body and footer cells of every frozen column — left: <total width of the frozen columns before it> for the left block, and the mirror image, right: <total width of the frozen columns beyond it>, for the right. Nothing runs in JS per scroll frame — that is the entire point. An earlier design counter-translated every frozen cell from the scroll handler, and because JS repositions them a frame after the compositor has already scrolled the rest, the frozen block visibly shook during horizontal scroll.

Sticky only works because ft-wrap is the single scrollport for both axes — which is why the rows are windowed by hand rather than by a virtualization library whose own overflow container would become the sticky scrollport for the body cells and break the freeze.

Frozen body cells use background: inherit, so they track the row's selection / hover / status background with no extra bookkeeping. The last frozen column gets a right-hand shadow once the table is scrolled.


9. Column resizing, hiding and reordering

Three more per-user layout choices, built the same way as the freeze boundary (§8): the column config carries the default, the user's choice lives in the table's own state, and the caller drives it through the ref. All three persist under pinStorageKey.

Resizing

Every header except the status strip carries a drag grip on its right edge — invisible until you hover it, then a thin blue line. Drag it to resize; double-click it to drop the override and go back to the configured width.

<FreezeTable
  columns={columns}
  data={rows}
  resizable            // default true — pass false to switch every grip off
  minColumnWidth={48}  // the drag cannot go below this
  pinStorageKey="sales-invoice-list"          // widths survive a reload
  onColumnResize={(id, width) => log(id, width)}
/>
  • Per column: disableResizing: true drops that column's grip.
  • The Action column is resizable too — a dragged width replaces the actionWidth prop for that list.
  • The drag paints a guide line and commits the width once, on release. It deliberately does not resize live: the column defs are what the memoized rows hang off, so a per-frame width would re-render every visible row sixty times a second. This is also what Excel and Sheets do.
  • A resized frozen column widens the frozen block, so the pin cap (§8) may quietly reduce the effective freeze count — the frozen block never gets to eat the viewport.

Hiding

// column config: the DEFAULT only
{ Header: 'Engine No', accessor: 'engine', width: 140, minWidth: 140, hidden: true }
{ Header: 'Invoice No', accessor: 'invoice', width: 150, minWidth: 150, hideable: false }
tableRef.current.toggleColumn('engine');        // flip one column
tableRef.current.toggleColumn('engine', true);  // or say which way
tableRef.current.setHiddenColumns(['engine', 'vin']);
tableRef.current.showAllColumns();

toolbar renders the menu (§5); building your own works exactly like the freeze menu. getColumnList() gives you one entry per caller column — { id, index, header, hidden, hideable, resizable, width } — so the menu never has to re-derive any of it from your column config:

{tableRef.current?.getColumnList().map((c) => (
  <label key={c.id}>
    <input
      type="checkbox"
      checked={!c.hidden}
      disabled={!c.hideable}
      onChange={() => {
        tableRef.current.toggleColumn(c.id);
        forceUpdate();          // the table owns the state; re-render your menu
      }}
    />
    {c.header || c.id}
  </label>
))}

Things worth knowing:

  • A hidden column is gone from the layout, not merely invisible: the freeze counts and the sticky offsets are computed from the visible columns only. Pin "first 3" with the second column hidden and you freeze the 1st, 3rd and 4th.
  • Its filter and sort stop applying while it is hidden, and come back when it is shown again (react-table keeps the state, and skips sort/filter entries whose column is not currently mounted).
  • Its footer total goes with it — hide the Amount column and the footer loses that cell.
  • A column needs an id (or a string accessor) to be hidden or resized — a column with only an accessor function cannot be addressed. react-table demands an id for those columns anyway.
  • hideable: false locks a column visible even against hidden: true, and hiding every column falls back to showing them all — a blank table has no header to un-hide from.

Reordering

Drag a header sideways to move that column. The drag arms only after a few pixels, so an ordinary click still sorts; while it is armed the header dims and a blue line shows where the column will land, and the order is committed on release. Dragging near either edge of the table auto-scrolls, so a column can be carried across a table far wider than the screen.

The Action column moves like any other columnactionIndex only says where it starts:

<FreezeTable
  columns={columns}
  data={rows}
  Actions={RowActions}
  actionIndex="first"      // 'last' (default) | 'first' | an index into `columns`
  reorderable              // default true — pass false to lock every header
  pinStorageKey="sales-invoice-list"
  onColumnOrderChange={(order) => log(order)}
/>
  • Per column: disableReordering: true locks that one in place.
  • The status strip is never movable — it belongs to the row, not to your columns.
  • Reordering is a mouse gesture. On a touch screen, dragging a header sideways is how the table is panned, and hijacking it would leave a wide table unscrollable.
  • A moved column keeps its sort, its filter and its width; what changes is where it sits, which is also what the freeze counts and the sticky offsets are computed from. Drag a column into the frozen run and it is frozen.

Driving it from a menu instead — getColumnList() comes back in display order, with a position (the index moveColumn takes) and a movable flag, and includes the Action column:

{tableRef.current?.getColumnList().map((c, i, list) => (
  <div key={c.id}>
    {c.header || c.id}
    <button disabled={!c.movable || i === 0}
            onClick={() => { tableRef.current.moveColumn(c.id, c.position - 1); forceUpdate(); }}>↑</button>
    <button disabled={!c.movable || i === list.length - 1}
            onClick={() => { tableRef.current.moveColumn(c.id, c.position + 1); forceUpdate(); }}>↓</button>
  </div>
))}

getColumnOrder() / setColumnOrder(ids) deal in the whole list at once, and resetColumnOrder() goes back to your columns array. A stored order is merged with the current config rather than trusted wholesale: ids the config no longer has are dropped, and a column you have since added lands next to the neighbour you configured it after — not dumped at the end as though the user had moved it there.

What gets persisted

With pinStorageKey="sales-invoice-list":

| Key | Holds | |----------------------------------|------------------------------------------| | ctPin:sales-invoice-list | left freeze count | | ctPinR:sales-invoice-list | right freeze count | | ctW:sales-invoice-list | {"<columnId>": <px>} — resized columns only | | ctHide:sales-invoice-list | ["<columnId>", …] — hidden columns | | ctOrd:sales-invoice-list | ["<columnId>", …] — the column order, "__actions" included |

Without the key nothing is stored and every choice lasts until unmount — unless you persist it yourself, which is what getLayout() / onLayoutChange are for (§5). The two can be combined: pinStorageKey for the fast per-browser restore, onLayoutChange for the copy that follows the user to another machine.


10. Per-row status colouring

Two independent, optional props for lists where a row carries a state (cancelled, failed to post, …). Both take the raw row object and should be pure functions.

rowStripColor(rowData) => color | null prepends a narrow column drawing a coloured bar at the left edge of the row. Return a falsy value for rows with no state. It is a real column, so it stays aligned with the header and scrolls with the row.

Pick a saturated colour. A pale tint that reads fine across a whole row (#ffe6e6) is invisible in a 4px bar.

rowStyle(rowData) => ({ backgroundColor?, color? }) tints the whole row. A returned backgroundColor wins over the selection and hover highlights — a status colour must never be masked by the blue selected-row background (row 0 is auto-selected on mount, which would otherwise hide its status the instant the list loads). Rows returning no backgroundColor highlight as normal.

Prefer the strip when the tint would be loud (a whole orange row is hard to read); use both only when the row colour is itself the requirement.

const STRIP = { Cancelled: '#e03e3e', Pending: '#e8912d', Posted: '#2aa76a' };

<FreezeTable
  rowStripColor={(r) => STRIP[r.status] || null}
  rowStripTitle={(r) => r.status}
  rowStyle={(r) => (r.status === 'Cancelled' ? { color: '#a11' } : undefined)}
/>

11. Restoring position on re-entry

When the list unmounts on navigation (list → edit → back), both scroll axes reset. Restore them with the pair below — each is applied once, after the rows load:

  • initialSelectedId (+ rowIdKey) → vertical scroll + re-highlights the row
  • initialScrollLeft → horizontal scroll (without it a wide table always snaps back to column 1)

Stash both in module-scope variables — they survive the unmount:

let lastSelectedId = null;
let lastScrollLeft = 0;

export default function List() {
  const tableRef = useRef(null);

  const goToEdit = (row) => {
    lastSelectedId = row.id;
    lastScrollLeft = tableRef.current ? tableRef.current.getScrollLeft() : 0;
    navigateToEdit(row.id);
  };

  return (
    <FreezeTable
      ref={tableRef}
      initialSelectedId={lastSelectedId}
      initialScrollLeft={lastScrollLeft}
      onRowSelect={(row) => { lastSelectedId = row.id; }}
      onRowEnter={goToEdit}
    />
  );
}

Both restore effects intentionally have no dependency array — they retry on each render until the rows and the measured body height exist, then latch via a ref.


12. Theming: tokens, classes, slots, unstyled

The table is designed to sit inside someone else's UI. Four layers, each off by default, each reaching something the one before it cannot:

| Layer | Prop | Use it when | |-------|------|-------------| | 1. Tokens | theme, tokens | You want the built-in table, in your colours | | 2. Classes | classNames | You write utility CSS and would rather pass classes than author a stylesheet | | 3. Slots | components | You have a design system and want its button, popover and input | | 4. Unstyled | unstyled | You want the freeze and the virtualization, and nothing else |

They compose. Most apps need only the first.

12.1 Why tokens and not props

Colour in this component lives in three places, and only a CSS custom property reaches all three:

  • inline styles on the header, rows, cells and footer — an inline declaration beats any stylesheet rule, so .ft-row { background: … } from your CSS would never apply;
  • the injected stylesheet's pseudo-class rules.ft-btn:hover, .ft-btn:focus-visible, .ft-menu-item[aria-checked="true"], .ft-thumb:active, .ft-resizer:hover::after. Two thirds of the colours are here, and no prop can express a :hover;
  • a JS handler, for the row hover — the row background is written directly onto element.style, which not even !important in your CSS can beat cleanly.

An inline style may hold a var(), and it resolves against the element's own cascade. So one variable set on .ft-root reaches a value that JavaScript wrote.

12.2 The token list

56 tokens. The ten marked are the core: everything else derives from them, so setting those six or seven re-themes the whole table, and setting one specific token re-themes exactly one thing.

| Token | Light default | Dark | |-------|---------------|------| | --ft-bg | #ffffff | #0f172a | | --ft-surface | #f4f5f7 | #1e293b | | --ft-text | #000000 | #e2e8f0 | | --ft-text-muted | #8a94a6 | #94a3b8 | | --ft-border | #e3e8ee | #334155 | | --ft-accent | #0070C2 | #38bdf8 | | --ft-accent-soft | #e9f2fb | #1e3a5f | | --ft-accent-text | #0a4d84 | #7dd3fc | | --ft-radius | 4px | (unchanged) | | --ft-font | inherit | (unchanged) | | --ft-header-bg | var(--ft-bg) | — | | --ft-header-text | var(--ft-text) | — | | --ft-row-bg | var(--ft-bg) | — | | --ft-row-hover | #eef4fb | #1e293b | | --ft-row-selected | #d3e5f8 | #1e3a5f | | --ft-row-border | #edf0f3 | #1e293b | | --ft-foot-bg | var(--ft-surface) | — | | --ft-foot-text | var(--ft-text) | — | | --ft-toolbar-bg | #fbfcfd | #111c30 | | --ft-menu-bg | var(--ft-bg) | — | | --ft-menu-border | #dde3ea | #334155 | | --ft-menu-text | #243447 | #e2e8f0 | | --ft-menu-head-text | #66738a | #94a3b8 | | --ft-menu-item-hover | #f0f5fa | #1e293b | | --ft-menu-item-active-bg | var(--ft-accent-soft) | — | | --ft-menu-item-active-text | var(--ft-accent-text) | — | | --ft-menu-sep | #eceff3 | #334155 | | --ft-menu-move-text | #8794a8 | #94a3b8 | | --ft-menu-move-hover | #dfe7f0 | #334155 | | --ft-radius-menu | 6px | (unchanged) | | --ft-btn-bg | var(--ft-bg) | — | | --ft-btn-text | var(--ft-menu-text) | — | | --ft-btn-border | #d7dde5 | #334155 | | --ft-btn-hover-bg | #f2f6fa | #1e293b | | --ft-btn-hover-border | #c2ccd8 | #475569 | | --ft-btn-active-bg | var(--ft-accent-soft) | — | | --ft-btn-active-border | #9dc4e8 | #38bdf8 | | --ft-input-bg | var(--ft-bg) | — | | --ft-input-text | rgba(0,0,0,.87) | #e2e8f0 | | --ft-input-border | rgba(34,36,38,.15) | #334155 | | --ft-input-focus-border | #85b7d9 | #38bdf8 | | --ft-input-placeholder | rgba(0,0,0,.35) | #64748b | | --ft-icon | #5a6b82 | #94a3b8 | | --ft-icon-muted | #c2cbd6 | #475569 | | --ft-sort-icon | var(--ft-text) | — | | --ft-search-icon | rgba(0,0,0,.45) | #94a3b8 | | --ft-spinner-track | rgba(0,0,0,.10) | rgba(255,255,255,.12) | | --ft-scrollbar | #c3ccd6 | #475569 | | --ft-scrollbar-hover | #a7b3c1 | #64748b | | --ft-scrollbar-active | #8c9bab | #94a3b8 | | --ft-resize-line | var(--ft-accent) | — | | --ft-drop-line | var(--ft-accent) | — | | --ft-focus-ring | var(--ft-accent) | — | | --ft-shadow-menu | 0 6px 20px rgba(20,32,48,.16) | 0 6px 20px rgba(0,0,0,.5) | | --ft-shadow-pin | 6px 0 6px -4px rgba(0,0,0,0.18) | 6px 0 6px -4px rgba(0,0,0,0.5) | | --ft-shadow-pin-right | -6px 0 6px -4px rgba(0,0,0,0.18) | -6px 0 6px -4px rgba(0,0,0,0.5) |

(unchanged) means the dark palette keeps the light value; means the token derives from a core one and follows it automatically.

At runtime: tokenNames(), CORE_TOKENS, LIGHT and DARK are exported, so you can build a theme from your own design tokens rather than transcribing this table.

12.3 Three ways to set them

theme — the built-in palettes.

<FreezeTable theme="dark" columns={columns} data={rows} />
<FreezeTable theme="auto" columns={columns} data={rows} />   {/* follows prefers-color-scheme */}

Omit theme if your app has its own dark toggle. A Tailwind dark: app already decides; a media query the table added on its own would fight it. Just set the variables:

.ft-root                { --ft-accent: #7c3aed; }
.dark .ft-root          { --ft-bg: #0f172a; --ft-surface: #1e293b; --ft-text: #e2e8f0;
                          --ft-border: #334155; --ft-row-hover: #1e293b;
                          --ft-row-selected: #1e3a5f; --ft-row-border: #1e293b; }

tokens — no CSS file at all. Inline custom properties on the root, inherited by everything inside (the toolbar menus included). Keys work with or without the prefix:

<FreezeTable
  tokens={{ accent: '#7c3aed', 'row-hover': '#faf5ff', radius: '8px', font: 'Inter, sans-serif' }}
  columns={columns}
  data={rows}
/>

Your own stylesheet. Same variables, scoped however you like — per table with classNames.root, per section, or globally on .ft-root.

Precedence, lowest to highest: injected defaults → your CSS → tokens → the style prop.

Your CSS always wins, whatever order the sheets load in. The default token blocks are wrapped in :where(), which contributes zero specificity — so a plain .my-table beats them, and you never have to reach for !important or care that the component injects its sheet at mount time (i.e. after your stylesheet). That also means theme="dark" composes rather than fights: set the prop for the palette, override --ft-accent in your own CSS, and you get both.

The rules that are not defaults — .ft-btn:hover, .ft-menu-item, .ft-thumb — keep their normal specificity. Those are component internals; overriding one is expected to take a selector that outranks it.

One shape note: --ft-font is applied as font-family, so give it a family stack (Inter, sans-serif), not a font shorthand value.

12.4 classNames — a class per slot

Merged after the component's own class, so in a flat-specificity setup (Tailwind and friends, where source order decides) yours wins.

<FreezeTable
  classNames={{
    root: 'rounded-lg border shadow-sm',
    toolbar: 'bg-slate-50',
    th: 'uppercase tracking-wide',
    row: 'hover:bg-violet-50',
  }}
/>

Every slot: root, toolbar, button, menu, menuItem, wrap, table, head, th, thLabel, thFilter, resizer, body, row, cell, foot, footCell, empty, loading, track, thumb. (CLASS_SLOTS at runtime.)

One caveat: a class cannot beat an inline style. classNames.row will not change the row background — that is what --ft-row-bg is for. Use classes for what the component does not set at all (rounding, shadow, letter-spacing, hover outlines), and tokens for what it does.

12.5 components — replace the piece

Recolouring will not make the table look like your design system. These will:

| Slot | Props it receives | |------|-------------------| | FilterInput | { value, onChange, onClick, placeholder }onClick must land on the input; it stops the click toggling the sort | | Button | { children, className, onClick, ...aria } — spread the rest onto a real <button>; aria-expanded carries the open state | | Menu | { children, align, className } | | MenuItem | { children, checked, icon, className, ...rest } — forward role, aria-checked, disabled, onClick, title | | MenuHeading | { children, note }note is the menu's current state, shown on the right | | CheckboxIcon | { checked } — the show/hide box in the Columns menu | | RadioIcon | { checked } — the selected dot in the Freeze menu | | GripIcon | — the drag-to-reorder grip on a Columns menu row | | MenuSeparator | — | | Spinner | { text } | | Empty | { text } | | SortIcon | { direction: 'asc' \| 'desc' \| null } | | PinIcon | { title, color, size } | | CheckIcon | { checked } | | ColumnsIcon | — |

import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'

const components = useMemo(() => ({
  Button: ({ children, ...rest }) => <Button variant="outline" size="sm" {...rest}>{children}</Button>,
  FilterInput: ({ value, onChange, onClick, placeholder }) => (
    <Input className="h-7 text-xs" value={value} onChange={onChange} onClick={onClick} placeholder={placeholder} />
  ),
}), [])

<FreezeTable components={components} columns={columns} data={rows} />
  • Memoize it, like columns — a fresh object literal each render costs work downstream.
  • null renders nothing: components={{ SortIcon: null }} drops the sort arrows without supplying a replacement. undefined falls back to the built-in.
  • Menu must not portal to document.body. It deliberately lives inside .ft-root (but outside .ft-wrap) so it inherits the table's tokens — and a scroll container inside the scrollport would break the column freeze (§2).

12.6 unstyled

No injected stylesheet, and no background, border, text colour, padding or font weight on any element. What stays is what is the table: position: sticky and the frozen left/right offsets, the absolute row placement at index * rowHeight, the flex layout, overflow, z-index and the measured widths.

<FreezeTable unstyled classNames={{ head: 'my-head', row: 'my-row', cell: 'my-cell' }} />

You must give .ft-row, .ft-head and .ft-foot a background. Frozen cells inherit their row's background; with a transparent one the scrolling columns show straight through the frozen block. That is the single thing unstyled cannot do for you.

12.7 CSP, shadow DOM, and two copies on one page

By default the component injects one <style> tag. Three situations need more:

  • A strict style-src with no 'unsafe-inline' — pass styleNonce={nonce}, or skip the injection entirely and import 'freeze-table/styles.css' (the same text, emitted by the build from the same source, so the two cannot drift). Without either, the table still renders: every inline var() carries its literal as a fallback. You lose the hover states, the scrollbar thumbs and theming — not the table.
  • Inside a shadow root — the sheet goes to the table's own root node by default, so this already works. styleTarget is there for a node you resolve yourself.
  • Two versions of the package on one page (a micro-frontend, an older transitive copy) — the <style> id carries a schema version, so each injects its own rather than the first one silently styling both.

12.8 Class and attribute hooks

Stable hooks for your CSS, for the component's own imperative repaint, and for your tests:

| Class / attribute | On | Used for | |------------------------|--------------------------------|-----------------------------------------| | .ft-root | root element | token scope, positioning context for the overlay bars | | .ft-wrap | scroller | scroll owner, focus target | | .ft-track-v / -h | overlay scrollbar tracks | restyle the bars | | .ft-thumb | scrollbar thumb | restyle the bars | | .ft-toolbar | toolbar strip (toolbar only) | restyle the strip | | .ft-btn | toolbar buttons | restyle the menu buttons | | .ft-menu | an open toolbar menu | restyle the popovers | | .ft-menu-item | one entry in a menu | aria-checked / [disabled] carry state | | .ft-head / .ft-th | header row / cell | — | | .ft-th-label | header label row | sort toggle click area | | .ft-th-filter | search-box wrapper | — | | .ft-resizer | grip on the header's right edge | the hover line (§9) | | .ft-resize-guide | root element | the line that follows a resize drag | | .ft-drop-line | root element | where a dragged column will land (§9) | | .ft-th-dragging | .ft-th being dragged | the dimmed header during a reorder | | .ft-row / .ft-td | row / body cell | selection repaint | | .ft-foot / .ft-tf | footer row / cell | — | | .ft-empty / .ft-loading | the two body states | — | | data-ft-theme | .ft-root | 'light' / 'dark' / 'auto' when theme is set | | data-ct-col | .ft-th | the column's id (reorder drop targets) | | data-ct-index | .ft-row | row index (selection repaint) | | data-ct-bg | .ft-row | the row's base background | | data-ct-custom | .ft-row | '1' when rowStyle returned a bg | | data-ct-pin | header / body / footer cell | '1' on frozen cells | | data-ct-pin-last | same | '1' on the boundary column |

Every element also carries the legacy ct-* twin of its class (ft-row ct-row), so a project migrating off a local copy keeps any existing selectors working.

Layout values, if you are matching something to them: cell padding 0 12px, header 7px 12px 9px, footer 8px 12px.


13. Gotchas

  1. Memoize data. react-table's autoResetSortBy / autoResetFilters are already disabled inside the component (otherwise every Object.values(byId) recreation silently cleared the sort, making a header click appear to do nothing), but an un-memoized array still causes needless row churn. In development the component now says so in the console when it sees the same contents arrive in a new array several renders running.
  2. Memoize columns — and when a Cell needs a callback, pass it through context (§3) rather than rebuilding the column array around it.
  3. Use status rather than wiring loading and dataFetched together, or the empty state flashes before the first fetch.
  4. height is the whole box. A number is pixels; a