A client-side TypeScript library for interactive, explorable data tables. Built on DuckDB WASM — all analytics run entirely in the browser, so no data ever leaves the user's machine.
- Per-column visualizations (histograms, value counts, date/time histograms) with brush/click crossfilter
- Manual filter UI per column + raw-SQL
WHEREfilters - Pin / hide / reorder / resize columns; virtual scrolling
- Derived columns (SQL expressions or JS-provided value vectors), with same-name dependency-aware replacement
- Stable synthetic
__rowid__+ read-only column export (actions.getColumnValues) for app-side row alignment and chart feeds - Programmatic row / column / cell annotations — severity tiers, intersection popover, JSON round-trip, session-persisted
- Programmatic column-header tooltips — XSS-safe structured popover (title / description / items + enum chips)
- Custom column-stats panels — replace the built-in two-line stats
display in a column header with your own DOM and DuckDB queries via
BaseStatsPanel+ per-instanceStatsPanelRegistry - Public CodeMirror SQL editor primitives — embed a schema- and
DuckDB-aware SQL editor anywhere in your own UI shell via
createSqlExtensions/buildCompletionContext(filter-preset composers, derived-column wizards, query-template editors); live or literal schema, optional library theme - Filter presets (import/export JSON)
- Undo/redo and IndexedDB session persistence
- CSV / JSON / Parquet export
- Automatic light/dark mode via CSS custom properties
npm install @jeyabbalas/data-table \
@duckdb/duckdb-wasm \
@codemirror/autocomplete @codemirror/commands @codemirror/lang-sql \
@codemirror/language @codemirror/state @codemirror/view @lezer/highlight@duckdb/duckdb-wasm is a required peer dependency. The @codemirror/*
and @lezer/* packages are optional peers — install them only if you use
the default SQL expression editor (for derived columns and raw-SQL filters).
If you supply your own editorFactory or disable those features, you can
omit them.
Give the container a bounded height — the table virtualizes against it, and without one virtualization is silently defeated. See Sizing the container.
<div id="my-table" style="height: 600px"></div>import { createDataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';
const table = await createDataTable({
container: document.getElementById('my-table')!,
source: myCsvFileOrUrl, // File | string URL | ArrayBuffer | Blob
});
table.on('filterChange', ({ filters, filteredRowCount }) => {
console.log(`${filters.length} filters, ${filteredRowCount} rows match`);
});
// When unmounting (e.g., route change in an SPA):
await table.destroy();For file pickers, URL inputs, or "swap dataset" flows, mount once and load
on user action — no destroy()/recreate dance needed:
const table = await createDataTable({
container: document.getElementById('my-table')!,
});
document.getElementById('file-picker')!.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) await table.loadData(file);
});loadData(source) accepts a File, an absolute URL (https://…,
http://…, file:, data:, blob:), a protocol-relative URL
(//host/…), a root-relative path (/data.csv), a dot-prefixed relative
path (./data.csv, ../data.csv), an ArrayBuffer, a Blob, or inline
CSV/JSON content (multi-line text, or a string starting with [ / {).
Relative URLs resolve against window.location — the same way <img src>
and fetch behave. Ambiguous strings (a single-line sample.csv with no
leading slash, for example) throw LoadError with code SOURCE_AMBIGUOUS
rather than silently parsing the literal text as CSV content.
Probe required browser APIs before mounting:
import { checkBrowserSupport } from '@jeyabbalas/data-table';
const support = checkBrowserSupport();
if (!support.supported) {
renderUnsupportedScreen(support.missing); // ['Worker', 'WebAssembly', …]
return;
}
// safe to call createDataTable(...) hereThe library is ESM-only since v0.4.0. Use import syntax (every modern
bundler — Vite, webpack 5, Rollup, esbuild, Bun — resolves the ESM build
by default). It is browser-only and not safe to evaluate during SSR — see
Framework integration below for client-side
mounting patterns.
The mount container must have a bounded height. This is a requirement, not a style preference — it is what makes the table fast.
The table is virtualized: it renders only the rows that fit in view, about
⌈containerHeight / rowHeight⌉ + 10 of them. It learns how many that is by
measuring the container. The container's height is therefore the knob that
caps how much work every scroll, filter, and sort has to do — a 600 px
container renders ~29 rows whether the dataset has 1 thousand rows or 10
million.
Give it a height in one of two ways:
<!-- 1. An explicit height — simplest, and always correct -->
<div id="my-table" style="height: 600px"></div>/* 2. A flex or grid child that fills the space left over */
html,
body {
height: 100%;
margin: 0;
}
body {
display: flex;
flex-direction: column;
}
#my-table {
flex: 1;
min-height: 0; /* required — see below */
}min-height: 0 is not optional in the flex case. Flex items default to
min-height: auto, which refuses to shrink below the content's intrinsic
height — and this table's content is every row in the dataset. Omit it and
you get the unbounded-growth failure below, in a container that looks like
it was sized correctly. The same applies to grid children.
height: 100% on the container alone does nothing unless every ancestor
up to the viewport also has a resolved height. That is the usual reason a
container that "has a height" still behaves as if it doesn't.
Nothing errors. The table renders, and on a small dataset it looks fine — which is exactly why this is easy to ship and painful later.
With no bounded height, the library's root element (height: 100%) resolves
against an auto-height parent and becomes content-sized. The scroll area then
grows to its full capped height — min(rowCount × rowHeight, 15,000,000 px)
— so the "visible region" the scroller measures is that entire range.
Virtualization is silently defeated: the table builds DOM for every row in
the range and issues block fetches to fill all of them.
At the default 32 px row height, the cap corresponds to ~468,750 rows, so that is where the damage saturates instead of scaling with the dataset — no comfort: hundreds of thousands of DOM rows and a churn of block fetches to fill them. The tab stalls, memory climbs, and scrolling is unusable. With a bounded container the same dataset renders ~29 rows and stays interactive.
The degenerate case is the opposite one: a container that is zero-tall at mount renders no rows at all, and the library logs a console warning saying so. A container that is merely unbounded gets no warning, because it has a perfectly good non-zero height — it is just the wrong one.
Give the container its height before mounting, and prefer a height that the
browser resolves through CSS (flex: 1, 100%, vh) over one you assign
imperatively after the fact. The scroller recomputes its visible range on
scroll and on state changes, so a container that changes height while idle
can keep a stale range until the next interaction. Sizing it up front avoids
the question.
Every runnable example uses the flex pattern, and
docs/integrations/cdn.md has a complete
copy-pasteable HTML starter.
Full documentation lives under docs/. A quick index:
Start here
- Quick start (above) · Runnable examples
- Sizing the container — the bounded height the table virtualizes against; read this before reporting a performance problem
- AGENTS.md — agent-facing guide: capability matrix, clarifying-question checklist, canonical snippets, pitfalls
Reference
- API reference — every option, event, action, error, filter shape, derived-column type
- Troubleshooting — 46 error codes and 26 common-issue FAQs with fix snippets
Guides
- Loading data · Filters · Derived columns · Events
- Annotations · Column-header tooltips · Visualizations · Stats panels · SQL editor primitives
- Session persistence · Theming · i18n · Accessibility · Multi-table dashboards
- CSP and offline · Filter presets
Concepts
Integrations
Source
- Tier-1 exports live in
src/index.ts; Tier-2 insrc/advanced.ts.
The library is browser-only — it uses Web Workers, window, document,
and IndexedDB directly, and is not safe to evaluate during SSR. Mount the
table inside your framework's client-side lifecycle hook.
import { useEffect, useRef } from 'react';
import { createDataTable, type DataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';
export function Table({ source }: { source: File | string }) {
const hostRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!hostRef.current) return;
let cancelled = false;
let instance: DataTable | undefined;
void createDataTable({ container: hostRef.current, source }).then((t) => {
if (cancelled) {
void t.destroy();
return;
}
instance = t;
});
return () => {
cancelled = true;
if (instance && !instance.isDestroyed()) void instance.destroy();
};
}, [source]);
return <div ref={hostRef} style={{ height: 600 }} />;
}The cancelled flag handles the case where the effect re-runs before
createDataTable resolves. isDestroyed() guards against double destroys
when React's Strict Mode double-invokes effects in dev.
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { createDataTable, type DataTable } from '@jeyabbalas/data-table';
import '@jeyabbalas/data-table/styles';
const host = ref<HTMLElement | null>(null);
let table: DataTable | undefined;
onMounted(async () => {
if (host.value) table = await createDataTable({ container: host.value, source: props.source });
});
onBeforeUnmount(async () => {
if (table && !table.isDestroyed()) await table.destroy();
});
</script>
<template>
<div ref="host" style="height: 600px" />
</template>After destroy(), the public methods (loadData, on, off,
openExportDialog, clearSession, setColorScheme) throw DestroyedError.
Check table.isDestroyed() in long-lived closures before calling them.
All features are on by default; pass false or a config object to customize:
| Option | Default | Notes |
|---|---|---|
persistence |
true |
Auto-save filters/sort/columns to IndexedDB |
presets |
true |
Show the "Presets" button for saving filter sets |
undoRedo |
true |
Ctrl/Cmd+Z and Ctrl+Y keyboard shortcuts |
expressionFilter |
true |
Show the "Expression" (raw SQL) filter button |
derivedColumns |
true |
Show the "+" add-column button and per-header f(x) edit icon |
visualizations |
true |
Auto-attach column header histograms / value counts |
exportDialog |
true |
table.openExportDialog() opens a CSV/JSON/Parquet modal |
For the full options surface (mounting, worker, UI, customization), see docs/api-reference.md#createdatatableoptions.
When both expressionFilter: false and derivedColumns: false are set, the
library no longer reaches the CodeMirror-bound modals at runtime. Consumers in
this configuration can omit the @codemirror/* and @lezer/highlight peer
dependencies (already marked optional in peerDependenciesMeta) — modern
bundlers chunk-split the modals into separate runtime modules that the
unreachable code paths never load. The programmatic API
(actions.addFilter({ type: 'raw-sql' }), actions.addDerivedColumn,
FilterPresetManager) keeps working in this mode. When the flags are on, the
first click of an SQL or derived-column button now fetches a small modal chunk
on demand instead of loading it eagerly on table mount.
Subscribe with table.on(event, handler) — returns an unsubscribe function.
The event bus covers ready, loadStart / loadProgress / loadComplete /
loadError, filterChange, sortChange, selectionChange, columnChange,
derivedChange (with a kind: 'added' | 'removed' | 'replaced' | 'updated'
discriminator and the affected columnName), undoChange, destroy, plus
error and warning for recoverable failure modes. Annotation mutations
flow through a separate table.annotations.on('change', …) channel — see
the annotations guide. For payload types
see docs/api-reference.md#event-catalog.
All colors, spacing, typography, and z-indices are driven by CSS custom
properties (74 --dt-* tokens — covering the full palette, sizing scale,
z-index stacking ladder, annotation severity tints, and column-header
tooltip layering). Override the ones you care about on :root, on a
per-instance element, or at runtime.
:root {
--dt-primary: #10b981;
--dt-radius: 4px;
--dt-z-modal: 1500; /* raise above your app's modal layer */
--dt-panel-width: 420px; /* widen filter / preset / derived-edit panels */
}Light/dark mode follows prefers-color-scheme by default. Pass
colorScheme: 'light' | 'dark' | 'auto' to force a theme, or call
table.setColorScheme(...) at runtime; body-portalled modals stay in sync.
See docs/guides/theming.md for the complete variable reference with light/dark defaults side-by-side, the dark-mode scoping model, stacking-ladder deep-dive, and per-instance override patterns.
All selectors carry the dt- prefix. Column-drag cursor, CodeMirror
autocomplete, and modal stacking are all scoped so they don't collide with
host styles. For stricter isolation (two copies of the library on one page,
strict brand walls), pass classPrefix: 'myapp-dt' and every selector,
modal, and tooltip re-renders with that prefix.
Shadow DOM is intentionally not used — modals portal into light DOM so they
can inherit --dt-* variables from :root. Wrap the library in a shadow
root yourself if you need that, and forward the theme variables +
portalTarget accordingly.
Every error extends DataTableError (which extends Error). Subscribe to
the error event to route by subsystem, or to warning for non-fatal
degradations like STYLESHEET_MISSING / PERSISTENCE_UNAVAILABLE:
table.on('error', ({ error, source }) => {
if (error.code === 'PARSE_FAILED') toast('Could not read that file.');
else if (source === 'persistence') console.warn(error);
else reportToSentry(error);
});For the full list of 34 error codes with triggers and fixes, see docs/troubleshooting.md.
- Multiple tables: share a
WorkerBridge(and optionally aSessionStore/FilterPresetManager) to avoid spinning up two DuckDB instances. Give each table a distincttableNameso session snapshots don't clobber each other. - CSP / air-gapped: self-host the worker and WASM bundles and pass
bridgeOptions.workerFactory+bridgeOptions.duckdbBundles.
See AGENTS.md §3(h) for a shared-bridge snippet, and docs/troubleshooting.md §4 for the CSP recipe.
The grid targets WCAG 2.1 AA: role="grid" with aria-rowcount /
aria-colcount, a single cursor published via aria-activedescendant and
driven by the arrow keys (plus F2 to reach a column's controls and
Shift+F2 to resize and reorder one), a tab order that doesn't grow with
column count, polite aria-live announcements on filter/sort/row-count
changes, focus trap + escape-to-close on every modal, and axe-core as a CI
gate. Known out-of-scope: in-cell editing, mobile touch gestures, RTL.
Subclass BaseVisualization (from /advanced) and register on a
per-instance VisualizationRegistry:
import { createDataTable, VisualizationRegistry } from '@jeyabbalas/data-table';
import { BaseVisualization } from '@jeyabbalas/data-table/advanced';
class MyViz extends BaseVisualization {
/* fetchData, render, … */
}
const registry = new VisualizationRegistry();
registry.register({
name: 'my-viz',
isApplicable: (t) => t === 'float',
constructor: MyViz as any,
priority: 10,
});
const table = await createDataTable({ container, source, visualizationRegistry: registry });Runnable version in examples/08-custom-visualization.
Every user-facing string comes from a typed Strings object. Pass a
DeepPartial<Strings> via messages; missing keys fall back to English
defaults. Messages are resolved once at construction — recreate the table
to switch locales.
await createDataTable({
container,
source,
messages: {
common: { close: 'Fermer', apply: 'Appliquer' },
filters: { panelTitle: 'Filtres' },
},
});Runnable version in examples/07-i18n-french.
| API | Used for |
|---|---|
Worker |
DuckDB runs in a dedicated worker |
WebAssembly |
DuckDB is compiled to Wasm |
IndexedDB |
Session persistence (skipped when persistence: false) |
ResizeObserver |
Column resize, responsive visualizations |
BigInt |
DuckDB integer columns cross the worker boundary as BigInt |
structuredClone |
Bridge snapshots result sets |
Roughly Chrome/Edge 98+, Firefox 94+, Safari 15.4+. Probe at runtime with
checkBrowserSupport() or opt into fail-fast init via
strictBrowserCheck: true. See
docs/api-reference.md#browser-support-probe.
The root entry (@jeyabbalas/data-table) exposes the facade plus a small
set of stable hooks. Power users who want to orchestrate the stack directly
— custom visualization lifecycles, headless use, driving the bridge
themselves — can import the building blocks from /advanced:
import {
TableContainer,
FilterBar,
ExportDialog,
BaseVisualization /* … */,
} from '@jeyabbalas/data-table/advanced';The /advanced surface trades stability for flexibility: it is not
covered by the same semver guarantees as the root entry. See
docs/api-reference.md#tier-2-exports
for the full symbol list.
See DEVELOPMENT.md for local setup, testing, the build pipeline, and the release workflow. See CONTRIBUTING.md for how to report bugs and submit changes.
MIT