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

@mvd/table

v0.2.0

Published

A fully-featured, CSS-variable-themable React table component built on TanStack Table v9.

Readme

@mvd/table

A batteries-included, CSS-variable-themable React table component built on TanStack Table v9 (beta).

TanStack Table is headless — it gives you state and row models and leaves the markup to you. This package is the other half: a single <DataTable /> that renders all of it, exposes every v9 feature behind an enable* prop, and is styled entirely through CSS custom properties so it can be made to look like Material, shadcn/ui, Ant, Linear, or a spreadsheet without overriding a single selector.

import { DataTable, createDataTableColumnHelper } from '@mvd/table'
import '@mvd/table/styles.css'

type Person = { id: string; name: string; age: number }

const helper = createDataTableColumnHelper<Person>()
const columns = helper.columns([
  helper.accessor('name', { header: 'Name' }),
  helper.accessor('age', { header: 'Age', meta: { align: 'right' } }),
])

export function People({ data }: { data: Person[] }) {
  return <DataTable columns={columns} data={data} getRowId={(row) => row.id} />
}

Keep columns and data stable. TanStack v9 requires it. The component defensively stabilizes arrays whose contents have not changed, but defining them outside render (or in useMemo) is still the right thing to do.

Contents

Install

pnpm add @mvd/table   # or npm install / yarn add

react and react-dom (>= 18) are peer dependencies. @tanstack/react-table and @tanstack/react-virtual are direct dependencies, so a plain install is enough.

Import the stylesheet once, anywhere in your app:

import '@mvd/table/styles.css'

Features

Each of these has a dedicated Storybook story.

| Area | Options | | --- | --- | | Sorting | enableSorting¹, enableMultiSort¹, enableSortingRemoval¹, sortDescFirst, maxMultiSortColCount, manualSorting, per-column sortFn | | Column filtering | enableColumnFilters¹, filterDisplayMode (popover | panel | popover-and-panel | none), filterPanelPosition, enableFilterModes, showActiveFilterChips¹, manualFiltering, enableMultipleFilterConditions, dataTypes, filterNow, 9 built-in data types | | Global filtering | enableGlobalFilter¹, globalFilterFn, enableGlobalFilterToggle¹ | | Faceting | enableFaceting¹ — auto-populates select/autocomplete/checkbox filter options | | Pagination | enablePagination¹, paginationDisplayMode, paginationPosition, pageSizeOptions, manualPagination, rowCount, pageCount, autoResetPageIndex | | Row selection | enableRowSelection (bool or predicate), enableMultiRowSelection¹, enableSubRowSelection¹, enableSelectAll¹, selectDisplayMode (checkbox | radio | switch), enableClickToSelect | | Cell selection | enableCellSelection, enableCellRangeSelection, enableMultiCellRangeSelection | | Column visibility | enableColumnVisibility¹, enableHiding¹ | | Column ordering | enableColumnOrdering, enableColumnDragging | | Column pinning | enableColumnPinning — start/end, sticky with an edge shadow | | Row pinning | enableRowPinning, rowPinningDisplayMode (sticky | top | bottom | top-and-bottom) | | Column resizing | enableColumnResizing, columnResizeMode, columnResizeDirection, keyboard-operable grips | | Grouping | enableGrouping, enableGroupingChips (drag-to-group), groupedColumnMode | | Aggregation | enableAggregation¹, per-column aggregationFn + aggregatedCell | | Expanding | enableExpanding, enableExpandAll¹, getRowCanExpand, paginateExpandedRows¹, renderDetailPanel | | Row utilities | enableRowNumbers, rowNumberDisplayMode, enableRowActions, renderRowActions, rowActionMenuItems, positionActionsColumn, enableRowOrdering | | Editing | enableEditing (bool or predicate), editMode (cell | row | table | modal), 5 editor variants, onEditingRowSave, onCellEditComplete, onDataChange | | Virtualization | enableRowVirtualization, rowVirtualizerOptions | | Layout | layoutMode (semantic | grid | grid-no-grow), density, height, maxHeight, direction (LTR/RTL) | | Header sizing | enableHeaderContentFit¹ — a column is never narrower than its own header | | Chrome | enableTopToolbar¹, enableBottomToolbar¹, enableToolbarInternalActions¹, enableDensityToggle¹, enableFullScreenToggle¹, enableColumnActions, enableStickyHeader, enableStickyFooter, enableStripes, enableRowHover¹, enableBorders | | States | isLoading, showProgressBars, isSaving, isLoadingError, errorMessage, skeletonRowCount, renderEmptyState | | i18n | localization — every string, including filter operator names | | Escape hatches | components, classNames, cssVars, tableProps, containerProps, rowProps, cellProps, headCellProps, renderTopToolbarActions, renderBottomToolbarActions, renderToolbarInternalActions, renderCaption |

¹ on by default; everything else is opt-in.

Feature registration

v9 gates state and instance APIs on an explicitly registered feature list. This component registers the whole stock set once, in dataTableFeatures, and gates behaviour with enable* props instead. That trades v9's per-feature tree-shaking for one stable table type and a single prop surface — if you need a minimal bundle for one narrow table, build your own tableFeatures({ … }) and use useTable directly.

Bring your own components

Every interactive control the table renders — buttons, inputs, selects, popovers, menus, the modal editor — comes from a component registry. Supply your design system once and the whole table adopts it:

import { DataTable, defaultComponents } from '@mvd/table'

<DataTable columns={columns} data={data} components={myComponents} />

components is a partial override: anything omitted keeps its built-in implementation, so replacing one control is as valid as replacing all of them.

Three constraints shaped the contract, each learned from making a real library satisfy it:

  • Overlays take a rendered trigger element, not a render prop. Radix clones it with asChild, MUI anchors to it, Mantine's Popover.Target clones it. A render prop would suit none of them.
  • Lists are data, not children. Mantine's Select and MultiSelect take a data array; a children-based option API simply cannot be backed by them. So Menu takes RtcMenuItem[] and Select takes options.
  • Overlays own their open state by default. Each library manages focus, dismissal and portalling differently; open/onOpenChange exist only for when the table genuinely needs control.

Structural markup — table, tr, th, td — is deliberately not in the registry. Column pinning, resizing and virtualization all depend on the exact DOM and data attributes the table emits, so those stay ours and are styled with CSS variables instead.

Working adapters for MUI, Radix/shadcn and Mantine live in stories/adapters/ and are exercised by both Storybook (15 UI Libraries) and the Playwright suite, which runs the same interaction tests against all three.

import type { DataTableComponents } from '@mvd/table'

const myComponents: Partial<DataTableComponents> = {
  // `...rest` is not optional. See below.
  Button: ({ children, onClick, variant, disabled, ...rest }) => (
    <MyButton kind={variant} onPress={onClick} isDisabled={disabled} {...rest}>
      {children}
    </MyButton>
  ),
}

The one hard rule

Button and IconButton must spread every prop they do not recognise onto the underlying element, and accept a ref.

Buttons are what overlays hang off, and each library delivers a trigger differently: Radix merges props through asChild, MUI clones to attach an anchorEl, Mantine clones to attach a reference ref and its own handlers. An adapter that destructures the props it knows and drops the rest renders a button that looks perfect and opens nothing — or one whose overlay has no element to measure and lands in the corner of the viewport. Nothing about it looks wrong in a screenshot.

e2e/overlays.spec.ts opens every overlay in every adapter for exactly this reason, and it is what actually enforces the rule. A new adapter has to pass it, along with the geometry checks that no control overflows its filter field and no header truncates its own label.

Nothing half-styled

Two rules keep an adapted table from ending up half its design system and half ours, and both are enforced by the same suite:

Every interactive control goes through the registry — including the ones that are easy to write as a plain <button>. The header's sort control and the pagination page numbers used to be raw elements, so a MUI table had MUI icon buttons sitting beside our own. The test asserts that no built-in primitive class (rtc-button, rtc-input, rtc-select, …) survives anywhere in an adapted table; the only way to satisfy it is to route everything through the registry and override everything in the adapter. Radix has no text input or select of its own, and the adapter styles plain elements rather than falling back to ours — otherwise the filter panel is half shadcn.

The stylesheet may only style what the table itself renders. Class names passed to registry components — rtc-th-sort, rtc-page-button, rtc-filter-operator — land on whatever the host rendered, so rules on those bare classes are limited to geometry. Chrome (background, border, radius, shadow) goes on .rtc-button.<class>, which only matches the built-in primitive. Typography is the deliberate exception for header labels: a header is content, not a button caption, and MUI's text button would otherwise render it uppercase and primary-blue, overruling the --rtc-header-* variables that exist to control exactly that.

One consequence worth knowing if you write an adapter: anything portalled needs the rtc-vars class. Radix, MUI and Mantine all render overlays into document.body, outside the table, where --rtc-* is not defined. An adapter stylesheet written against those variables produces a menu with no background; worse, an icon we hand the library as menu-item content loses --rtc-icon-size, and an SVG whose width is an invalid var() falls back to its intrinsic size — a 16px glyph rendering at 300px. Icons carry a literal fallback for that reason, but the class is what keeps everything else themed. The class resolves the palette from wherever the portal lands, so in dark mode it only works if data-rtc-theme is somewhere both the table and document.body can see it — see Theming.

The built-in overlays

The defaults use the platform's Popover API: a popover attribute, popovertarget on the trigger, and the top layer. That is worth more than novelty here. The top layer means a filter popover opened from a header is not clipped by the table's scroll container and needs no z-index, so nothing has to be portalled — and because it is not portalled, a menu opened inside a popover is a real DOM descendant of it. That is how the platform decides two popovers are nested, so light dismiss, Escape ordering and "closing me closes my children" all come from the browser rather than from a hand-maintained overlay stack.

Positioning is still JavaScript: CSS anchor positioning would replace it, but it is not yet in Firefox or Safari, and an overlay in the wrong corner is a worse failure than a few lines of measurement.

Filtering

There is no in-table filter row. A row of filter inputs forces every row to the height of the tallest editor, and the useful editors — date ranges, checkbox groups, range sliders — are tall. Filters live in two places instead, selected with filterDisplayMode:

| Mode | Behaviour | | --- | --- | | popover (default) | A funnel button in each header opens that column's editor in a popover. | | panel | A vertical, independently scrolling pane docked beside the table. | | popover-and-panel | Both. | | none | No built-in UI; drive columnFilters yourself. |

Because the editors are hidden until opened, active filters surface as removable chips in the toolbar (showActiveFilterChips, on by default) and the filtered column's header carries data-rtc-filtered.

The panel is also a standalone export, so it does not have to live inside the table at all:

const table = useDataTable({ columns, data, filterDisplayMode: 'none' })

return (
  <Layout>
    <Sidebar>
      <DataTableFilterPanel table={table} />
    </Sidebar>
    <DataTable table={table} />
  </Layout>
)

It installs its own component registry from the table's options, so it works anywhere in the tree.

Filter data types

A column's data type decides what you can ask of it. A date is not a string with a calendar icon: "in the last 3 weeks", "any Tuesday", "between 09:00 and 17:00 on any day" are all questions a text box cannot express, and none of them is a variant of the others. So the operator list, the predicate, and the operand editor all come from the type — not from the filter component, which knows nothing about dates or coordinates.

helper.accessor('lastSeen', {
  header: 'Last seen',
  meta: {
    dataType: 'datetime',
    filterTypeMeta: { dateTimeZone: 'utc' },   // per-column type config
    filterOperators: ['dateInLast', 'dateBetween'], // optional subset
  },
})

Declare nothing and the type is inferred from the first non-empty cell — convenient for a quick table, but anything load-bearing should say what it is.

Built-in types

| Type | Operators | | --- | --- | | text | contains, equals, startsWith, endsWith, matchesRegex, isAnyOf, isOneOfChecklist, isEmpty, isNotEmpty | | enum | equals (faceted picker), isAnyOf, isOneOfChecklist, isEmpty, isNotEmpty | | number | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between, betweenExclusive, inRangeSlider, isAnyOf, isEmpty, isNotEmpty | | duration | the numeric operators, with a unit-aware operand and summary (filterTypeMeta.durationUnit) | | boolean | booleanIs (tri-state), isEmpty, isNotEmpty | | date / datetime | dateIs, dateBefore, dateOnOrBefore, dateAfter, dateOnOrAfter, dateBetween, dateInPeriod, dateInLast, dateInNext, dateWeekdayIs, dateTimeOfDayBetween, isEmpty, isNotEmpty | | collection | containsAnyOf, containsAllOf, containsNoneOf, countEquals, countAtLeast, isEmpty, isNotEmpty | | geoPoint | geoWithinRadius (haversine), geoWithinBounds, isEmpty, isNotEmpty |

Temporal detail worth knowing:

  • Granularity is orthogonal to the operator. Every comparison truncates to filterTypeMeta.dateGranularityday for date, minute for datetime — so "on 3 March" matches a row stored at 14:37 on 3 March.
  • Periods (dateInPeriod) covers 14 named windows: today, yesterday, tomorrow, this/last/next week, month, quarter and year.
  • Rolling windows (dateInLast / dateInNext) take { n, unit } and are re-evaluated against a single now per filter pass, so rows either side of a boundary are judged against the same clock. Pass filterNow to pin it, which is what the stories and tests do.
  • Time of day is matched independently of the date, and a window that wraps midnight (22:00–02:00) is treated as a union rather than an empty range.
  • Day/week arithmetic is calendar-based rather than n × 86400000, so it survives DST.

The filter value

The operator lives inside the value, not beside it:

{ "id": "age", "value": { "op": "between", "value": [30, 40] } }
// several conditions on one column:
{ "id": "age", "value": { "join": "or", "conditions": [
  { "op": "lessThan", "value": 30 },
  { "op": "greaterThan", "value": 60 }
] } }

That makes columnFilters self-describing: it round-trips through a URL, a saved view or an API without a parallel map of which function each column happens to be using. A bare value ("Engineering", [30, 40]) is still accepted and read as the operand of the type's default operator, so hand-seeded state keeps working.

Set enableMultipleFilterConditions (per table or per column) to surface the "add condition" control and the and/or joiner.

Cross-cutting concerns are modelled as modifiers on a condition rather than by doubling the operator list: negate, caseSensitive, ignoreDiacritics and includeNulls.

Adding a type

A type is an object; there is nothing to subclass and no component to change.

const semverDataType: ColumnDataType = {
  id: 'semver',
  defaultOperator: 'semverAtLeast',
  Operand: TextOperand,           // default operand editor
  operators: [
    {
      id: 'semverAtLeast',
      label: 'Is at least',
      arity: 1,
      test: (dataValue, operand) => compare(parse(dataValue), parse(operand)) >= 0,
    },
    {
      id: 'isEmpty',
      label: 'Is empty',
      arity: 0,
      Operand: NoOperand,          // per-operator override
      isIncomplete: () => false,
      test: (dataValue) => parse(dataValue) == null,
    },
  ],
  describe: (condition, ctx) => `${ctx.columnLabel} ≥ ${condition.value}`,
}

<DataTable dataTypes={{ semver: semverDataType }} … />

Register it by id through dataTypes, or pass the object straight to meta.dataType for a one-off column. Composing beats writing from scratch — spread a built-in and add an operator:

meta: {
  dataType: {
    ...numberDataType,
    id: 'salary',
    operators: [...numberDataType.operators, isRoundNumber],
  },
}

An operator declares its arity (0 | 1 | 2 | 'n'), a test, and optionally an Operand editor, an initialValue for when it is selected, usesFacets to receive the column's faceted values, and isIncomplete so a half-entered condition does not hide every row while the user types. Operand editors render through the component registry, so they pick up the host design system like everything else.

Types that are deliberately not built in, but are a few lines each on this model: IP address / CIDR, semantic version, colour (ΔE distance), JSON path, relation or reference with async options, file size and MIME type, rating, currency with conversion, and polygon or named-region geo matching.

Grouping

enableGrouping lets a column collapse its rows into group rows, from the column actions menu or by dragging a header into the chip zone (enableGroupingChips). Grouping implies expanding: group rows get a chevron in the expand column whether or not enableExpanding is set.

groupedColumnMode decides where a grouped column goes.

| Mode | Column order | Group row shows | | --- | --- | --- | | 'reorder' (default) | Grouped columns move to the front, ahead of the expand column | The value in its own column | | 'remove' | Grouped columns leave the table; the expand column widens and takes their header | The value and row count next to the chevron | | false | Untouched; the expand column leads | The value in its own column, wherever that is |

<DataTable
  columns={columns}
  data={data}
  enableGrouping
  groupedColumnMode="remove"
  initialState={{ grouping: ['department'] }}
/>

A group row stands for many records, so the display columns that address a single one — the drag grip and the row actions — stay blank on it. Columns with an aggregationFn summarise their group there instead; see aggregatedCell for how that summary renders.

Theming

Everything visual comes from custom properties declared on .rtc-root. Nothing in the stylesheet hard-codes a colour, radius, or spacing value outside that block, so a handful of overrides restyles the whole table.

<DataTable
  columns={columns}
  data={data}
  cssVars={{
    '--rtc-color-accent': '#e11d48',
    '--rtc-radius': '20px',
    '--rtc-row-height-comfortable': '64px',
    '--rtc-header-text-transform': 'uppercase',
  }}
/>

Seven presets ship with the package:

import { materialTheme } from '@mvd/table'

<DataTable columns={columns} data={data} cssVars={materialTheme} />

material, shadcn, ant, linear, spreadsheet, soft, highContrast — each a plain Record<string, string>, so copy one as a starting point.

Variables are grouped as --rtc-color-*, --rtc-font-* / --rtc-header-*, metrics (--rtc-radius, --rtc-cell-padding-*, --rtc-row-height-*), surfaces (--rtc-row-bg-*, --rtc-cell-bg-*) and effects (--rtc-shadow-*, --rtc-focus-ring, --rtc-transition). See src/styles.css for the full list with defaults.

Dark mode follows prefers-color-scheme. Force it either way with data-rtc-theme="dark" / "light" on the table or any ancestor.

Put the attribute high enough. Not everything the table opens stays inside it: the modal editor portals to document.body, and every adapter renders its menus and popovers there too. Those surfaces carry rtc-vars and so resolve the palette from their ancestors, which is the body — not the wrapper the table happens to sit in. Setting data-rtc-theme on a <div> around the table leaves them light while the table is dark. Set it on <html> (or <body>), as an app switching themes would anyway, and everything lands on the same palette.

The palette also sets color-scheme, which is what makes the browser paint its own chrome to match: scrollbars, the drop-down list of a native <select>, date pickers, number spinners. Without it a dark table opens a white select popup, which no amount of variable overriding can reach.

A design system with a colour scheme of its own — MUI's ThemeProvider, Mantine's MantineProvider — is not driven by any of this. Tell it the mode directly, alongside the attribute:

<ThemeProvider theme={createTheme({ palette: { mode } })}>
  <DataTable columns={columns} data={data} components={muiComponents} />
</ThemeProvider>

Column widths

A header is not just a label: it carries a sort control, a filter funnel and a column menu. Give a column size: 90 with all three enabled and the label is squeezed to nothing, while the table may still have empty space beside it.

So a declared size is a preference and the header's own content is a floor. When the floor pushes the total past the container the table scrolls horizontally — the same trade AG Grid's header auto-size makes, and the right one: a scrollbar is recoverable, a header truncated to A… is not. Sizing to body content is deliberately not the default, since one long cell blows the column out.

This is a stylesheet rule, not a measurement pass. A header truncates because something told the browser it may: overflow: hidden on the cell gives it a min-content width of zero. Body cells still clip — their content is data, and one long email address must not set a column's width — but header cells do not, and carry min-width: min-content. The browser's own table algorithm does the rest, and keeps doing it through font swaps, translated labels and density changes, none of which JavaScript would reliably hear about.

One number still crosses in code, in the grid layout modes only. There each row is its own flex container, so a header that grows to fit its label grows alone and slides out of alignment with the cells beneath it. subgrid is the real answer — one set of column tracks spanning header and body, sized intrinsically — but the virtualized body takes its rows out of flow with position: absolute, so they would not be grid items. Until that changes, the header's min-content width is read once per layout change and published to the column as a custom property.

Set enableHeaderContentFit={false} to restore clipping and let columns shrink to whatever the container allows.

Server-side data

Set the manual* flags, feed the table the current page, and tell it the total row count:

<DataTable
  columns={columns}
  data={page.rows}
  manualPagination
  manualSorting
  manualFiltering
  rowCount={page.total}
  isLoading={isFetching}
  showProgressBars={page.rows.length > 0}
  isLoadingError={isError}
  state={{ pagination, sorting, globalFilter: search }}
  onPaginationChange={applyPaginationUpdater}
  onSortingChange={applySortingUpdater}
  onGlobalFilterChange={(next) => {
    applySearchUpdater(next)
    setPagination((old) => ({ ...old, pageIndex: 0 }))
  }}
/>

A complete working example — including the MSW handler it runs against — lives in stories/04-Pagination.stories.tsx and stories/remoteApi.ts, covered end-to-end by e2e/remote-pagination.spec.ts.

Editing

Four modes, matching Material React Table's:

  • cell — double-click a cell; commits on blur or Enter, reverts on Escape.
  • row — an edit action turns the row into inputs with save/cancel.
  • table — every cell is an input at once.
  • modal — an edit action opens a focus-trapped dialog.
<DataTable
  columns={columns}
  data={rows}
  enableEditing
  editMode="row"
  onEditingRowSave={({ values, exitEditingMode }) => {
    save(values)
    exitEditingMode()
  }}
/>

Editor widgets come from meta.editVariant: text, number, select, checkbox, date. Generated columns (selection, expand, actions) are never editable, and neither are display columns without an accessor.

Controlling state

Each slice can be left alone, observed, or fully controlled — independently.

// Uncontrolled, with a starting value.
<DataTable initialState={{ sorting: [{ id: 'name', desc: false }] }} … />

// Observed.
<DataTable onSortingChange={(updater) => log(updater)} … />

// Controlled.
<DataTable state={{ sorting }} onSortingChange={applySortingUpdater} … />

initialState also accepts the presentation-only slices — density, isFullScreen, showColumnFilters, showGlobalFilter, rowOrder, columnFilterFns — which makes saving and restoring a user's layout a single object. onStateChange reports all of it, replacing the global callback v9 removed.

To read table state from the surrounding page, build the instance yourself:

const table = useDataTable({ columns, data, enableRowSelection: true })
const selected = Object.keys(table.state.rowSelection).length
return <DataTable table={table} />

Accessibility

  • Native <table> semantics, with scope, aria-sort, aria-rowcount/aria-colcount, aria-selected, aria-expanded and aria-busy wired up.
  • Every icon-only control takes its accessible name from localization.
  • Anything that names a column — the visibility menu, the grouping chips, the filter panel, "Sort by {column} ascending" — reads a plain-string header, falling back to meta.label when the header is a render function or an element. The columns the component generates (selection, expand, row number, row actions) are named from localization, so they are readable and translated rather than showing an internal id.
  • Menus are portalled, dismiss on Escape and outside click, support arrow-key roving focus, and restore focus to their trigger.
  • The modal editor traps focus and is labelled role="dialog" aria-modal.
  • Column resize grips are role="separator" and respond to arrow keys; row drag handles reorder with up/down arrows — both are usable without a pointer.
  • prefers-reduced-motion disables animation and transitions.
  • Opt into arrow-key cell navigation with enableKeyboardNavigation.

Storybook

Every story file gets an autodocs page, and the Code panel beside the canvas shows the story's own source with a copy button. source.type is code rather than dynamic: these stories are render functions, and the dynamic snippet would show the rendered element tree instead of the code worth copying.

Development

This repo uses pnpm (pinned via packageManager; corepack enable picks it up automatically).

pnpm install
pnpm run storybook        # http://localhost:6006 — 114 stories
pnpm run typecheck
pnpm run build:lib        # dist/index.js + dist/style.css + dist/index.d.ts
pnpm run build:storybook  # → storybook-static/
pnpm run test:e2e         # Playwright, against the built Storybook

pnpm install will ask to approve build scripts the first time. esbuild is on the approved list because Storybook's core needs its postinstall to link a platform binary; without it build:storybook fails.

The Playwright suite (158 tests) drives the real Storybook build: it starts vite preview over storybook-static/, so run pnpm run build:storybook first. The remote-pagination specs intercept /api/people with Mock Service Worker.

If your environment provides its own Chromium instead of Playwright's managed download, point the suite at it:

CHROMIUM_PATH=/path/to/chromium pnpm run test:e2e

Changesets

Versioning and the changelog are handled by changesets. Any change a consumer could notice ships with one:

pnpm changeset            # pick the bump, write the entry
pnpm changeset:status     # what is pending against main

The version in package.json is never edited by hand. .github/workflows/release.yml keeps a "Version Packages" pull request open while changesets are pending on main; merging it applies the bumps, writes CHANGELOG.md, and publishes to npm. See .changeset/README.md for which bump to pick.

Build toolchain

The library is bundled by tsdown, which runs on rolldown and emits declarations through rolldown-plugin-dts; CSS goes through lightningcss via @tsdown/css. Storybook builds on Vite 8, which is also rolldown-backed. No rollup anywhere; the only remaining esbuild is Storybook core's own internal dependency, which cannot be removed without dropping Storybook.

Two things about the output worth knowing:

  • The bundle is not minified. That is deliberate for a library — consumers minify, and shipping readable code plus a sourcemap makes debugging possible.
  • The stylesheet is emitted as dist/style.css (tsdown's name) and exposed on the stable @mvd/table/styles.css subpath. Importing the package's JS does not inject styles; the CSS import is separate and explicit.

Deployment

Storybook is published to Cloudflare Pages:

| Setting | Value | | --- | --- | | Build command | pnpm run build:storybook | | Output directory | storybook-static | | Node version | from .nvmrc (22) |

Layout

src/
  DataTable.tsx        top-level component
  useDataTable.tsx     state ownership + TanStack wiring
  features.ts          the registered v9 feature set
  types.ts             the public option surface
  styles.css           theme variables + structural CSS
  themes.ts            preset variable maps
  locale.ts            localizable strings
  editing.ts           edit-mode rules
  displayColumns.tsx   generated select/expand/number/actions columns
  dragContext.tsx      pointer-based drag reordering
  components/          head, body, toolbar, filters, menus, primitives
  components/
    registry.tsx       the component contract + provider
    defaultComponents  the built-in, dependency-free implementation
    FilterPanel.tsx    standalone filter pane
    FilterEditor.tsx   per-variant editor, shared by popover and panel
stories/               one file per feature area
stories/adapters/      MUI, Radix and Mantine registry adapters
e2e/                   Playwright specs
tsdown.config.ts       library build (rolldown + lightningcss)

License

MIT