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

@nexgrid/vanilla

v0.2.1

Published

Zero-dependency vanilla JS/DOM renderer for TableX — the professional, server-driven data grid. Also powers TableX.AspNetCore.

Readme

@nexgrid/vanilla

A professional, server-driven data grid for plain JavaScript — no framework, no build step required, and zero runtime dependencies beyond @nexgrid/core.

The grid never holds your dataset. Every user action (search, sort, page, page size) becomes a QueryState; you answer with one page of rows and a total, or you point the grid at an endpoint and let it fetch for itself.

  • 🗂️ Column Header Grouping (Multi-Level / Stacked Headers) — Group sub-columns beneath parent categories with automatic colSpan and rowSpan.
  • 🚀 Client-Side Pagination & In-Memory Engine (clientSidePagination) — Zero-config in-memory paging, sorting, search, filtering, and export over local arrays with reactive setData().
  • 💾 Grid State Persistence (storageKey) — Automatically saves column widths, drag order, hidden columns, and density to localStorage + 1-click Reset View.
  • ↔️ Interactive Column Resizing & Double-Click Auto-Fit — Drag handles to adjust column widths, double-click to measure and auto-fit to content.
  • 🏷️ Active Filter Pills Bar — Interactive chip badges beneath the toolbar for active search & column filters with one-click removal and "Clear all".
  • 🔍 Global search (350 ms debounce) · 3-dot column filters · column visibility menu · density menu
  • 🔄 Server sorting with the asc → desc → cleared cycle and multi-column sorting
  • ☑️ Row selection, automatic S.No. column, row click
  • 📊 Excel (.xls, styled) and CSV export, including whole-dataset export
  • 📱 Responsive: a table at ≥ 768 px, a card list below — same renderers in both
  • 🎨 Fully localizable, light/dark/auto theming, keyboard and screen-reader ready
  • 🛡️ Safe by construction: cell values are written as text nodes. There is no innerHTML path for row data anywhere in this package.

This is also the bundle that powers TableX.AspNetCore.


Install

npm

npm install @nexgrid/vanilla
import { createTableX } from "@nexgrid/vanilla";
import "@nexgrid/vanilla/styles.css";

Script tag / CDN

The browser bundle inlines @nexgrid/core and exposes everything on a global called TableX.

<link rel="stylesheet" href="https://unpkg.com/@nexgrid/[email protected]/dist/tablex.css" />
<script src="https://unpkg.com/@nexgrid/[email protected]/dist/tablex.global.js"></script>
<script>
  const grid = TableX.createTableX(document.getElementById("grid"), {
    caption: "Students",
    endpoint: "/api/students",
    columns: [{ accessorKey: "name", header: "Name" }],
  });
</script>

Serving the files yourself? Copy dist/tablex.global.js and dist/tablex.css out of the package — that pair is self-contained.


Quick start

Endpoint mode — the grid fetches its own data

Point it at any endpoint that accepts TableX's query string and answers with a PagedResponse. That is the wire format TableX.AspNetCore binds and returns out of the box:

GET /api/students?page=2&pageSize=25&sort=name:asc&q=smith&filter[status]=Active

{ "items": [...], "page": 2, "pageSize": 25, "total": 137, "totalPages": 6 }
const grid = TableX.createTableX(document.getElementById("grid"), {
  caption: "Students",
  endpoint: "/api/students",
  columns: [
    { accessorKey: "name", header: "Name", meta: { minWidth: 180 } },
    { accessorKey: "email", header: "Email" },
    { accessorKey: "status", header: "Status", meta: { align: "center" } },
    { accessorKey: "score", header: "Score", meta: { align: "right", width: 90 } },
  ],
  enableSelection: true,
  onSelectionChange: (ids) => console.log(ids),
  onNotify: ({ type, message }) => myToast[type](message),
});

The grid fetches on mount and on every query change, shows its own loading spinner, renders an error card with a working retry button when a request fails, and discards responses from requests that have already been superseded.

Controlled mode — you own the data

Supply data, total, query and onQueryChange. The grid renders exactly what you gave it and emits intent; it does not move on its own — fetch the new page and call handle.update():

let query = TableX.defaultQuery();

const grid = TableX.createTableX(document.getElementById("grid"), {
  caption: "Students",
  columns,
  data: [],
  total: 0,
  query,
  onQueryChange: (next) => {
    query = next;
    load(next);
  },
});

async function load(next) {
  grid.update({ isLoading: true });
  try {
    const res = await fetch(TableX.buildQueryUrl("/api/students", next));
    const body = await res.json();
    grid.update({ data: body.items, total: body.total, query: next, isLoading: false, error: false });
  } catch {
    grid.update({ isLoading: false, error: true });
  }
}

load(query);

Use controlled mode when the data does not come from a single URL — a GraphQL client, a websocket feed, an in-memory store, or a query already owned by your app's router.


Options

createTableX(container, options)container is any element; the grid appends one div.tbx-root to it.

Required

| Option | Type | Description | | --- | --- | --- | | columns | TableXColumn<TData, string \| Node>[] | Column definitions, in display order (supports nested columns for multi-level stacked headers). | | caption | string | Accessible name for the table; also the default export file prefix. |

Data source — pick one mode

| Option | Type | Default | Description | | --- | --- | --- | --- | | clientSidePagination | boolean | false | Zero-config in-memory mode: slices data, sorts, filters, searches, and exports locally. | | data | TData[] | [] | The current page of rows (or full dataset if clientSidePagination is true). | | total | number | 0 | Total filtered row count. Drives the pager (computed automatically in client mode). | | query | QueryState | defaultQuery() | Initial page / size / sort / search / filters. | | onQueryChange | (next: QueryState) => void | — | Fires on every query change, in both modes. | | endpoint | string | — | Enables endpoint mode: the grid fetches buildQueryUrl(endpoint, query) itself and manages loading/error. | | fetchOptions | RequestInit | — | Extra fetch init (headers, credentials, …) for endpoint mode and for export's fetch-all pass. signal is always supplied by the grid. |

Presentation

| Option | Type | Default | Description | | --- | --- | --- | --- | | density | "compact" \| "default" \| "comfortable" | "default" | Initial row density; the user can change it from the toolbar. | | theme | "light" \| "dark" \| "auto" | "light" | Adds tbx-dark / tbx-auto to the root. | | className | string | — | Extra classes appended to .tbx-root. | | isLoading | boolean | false | Controlled mode: show the loading state (rows only). | | error | boolean | false | Controlled mode: replace the whole grid with the error card. | | onRetry | () => void | — | Adds a retry button to the error card. In endpoint mode a retry button is shown regardless and refetches. |

Features

| Option | Type | Default | Description | | --- | --- | --- | --- | | storageKey | string | — | Persists custom column widths, column order, hidden columns, and density to localStorage. | | enableColumnResize | boolean | true | Interactive drag resize handles and double-click auto-fit measuring. | | enableColumnFilters | boolean | true | 3-dot column filter popovers (⋮) with search, select, and range filtering. | | enableSummaryRow | boolean | false | Bottom aggregation / summary row (sum, avg, min, max, count). | | enableRowExpansion | boolean | false | Master-detail accordion expandable sub-rows with rotating chevrons. | | enableBulkActions | boolean | false | Contextual floating bottom pill bar for batch operations. | | showSerialNumber | boolean | true | The automatic S.No. column, numbered across the whole result set. | | enableSearch | boolean | true | Global search field, debounced 350 ms. | | searchPlaceholder | string | locale.searchPlaceholder | Placeholder text override. | | enableSelection | boolean | false | Row checkboxes and a header select-all for the current page. | | selectionMode | "multi" \| "single" | "multi" | Single or multi-row selection mode. | | onSelectionChange | (ids: string[]) => void | — | The running selection. | | onRowClick | (row: TData) => void | — | Row/card click. Adds a pointer cursor. Checkbox clicks never trigger it. | | getRowId | (row: TData) => string | row.id ?? String(row) | Stable row identity for selection. | | toolbarActions | Node \| string | — | Rendered at the end of the toolbar. |

Export

| Option | Type | Default | Description | | --- | --- | --- | --- | | enableExport | boolean | true | The Excel / CSV export menu. | | exportFileName | string | filePrefixFromCaption(caption) | File prefix. CSV files also get a _export_YYYY-MM-DD suffix. | | onExportAll | () => void \| Promise<void> | — | Replaces the built-in export entirely. | | fetchEndpoint | string | options.endpoint | Used to walk every page when exporting more than the current one. | | badgeRules | readonly ExcelBadgeRule[] | DEFAULT_BADGE_RULES | Value-based colouring in the Excel export. |

Localisation & messaging

| Option | Type | Default | Description | | --- | --- | --- | --- | | locale | Partial<TableXLocale> | DEFAULT_LOCALE | Overrides for any user-facing string. | | onNotify | (notice: { type, message }) => void | no-op | The grid never renders toasts; it reports through this. |

Export flow

  1. onExportAll set? It is called and the grid does nothing else.
  2. If the current page is the whole result set, or no fetchEndpoint/endpoint is available, the current page is exported.
  3. Otherwise you get an info notice, the grid walks every page (100 rows at a time, capped at 2 000) preserving the active search, sort and filters, and falls back to the current page with an error notice if that fails. The export button is disabled and reads "Exporting…" for the duration.
  4. Excel gets styled headers, zebra striping, a serial column and badge colours; CSV is RFC 4180 with a UTF-8 BOM and formula-injection neutralisation.
  5. A success notice reports the row count.

Handle API

createTableX returns a handle:

| Method | Description | | --- | --- | | update(patch) | Patch data, total, query, isLoading and/or error. Omitted keys are untouched. In endpoint mode a changed query triggers a refetch. | | refresh() | Endpoint mode: refetch the current query. Controlled mode: re-render. | | getQuery() | The query currently displayed. | | getSelection() | Ids of the selected rows. | | destroy() | Detaches the grid, aborts any in-flight request, clears the search timer and removes every document listener it registered (outside-click, Escape). Safe to call twice. |

grid.update({ data: rows, total: 137, query, isLoading: false });
const ids = grid.getSelection();
grid.destroy();

Call destroy() when the containing view goes away. Every listener the grid put on document is tracked and released there.


Columns

Column definitions are structurally compatible with TanStack Table's ColumnDef, so column sets written for a TanStack grid work here unchanged.

{
  id?: string;              // or accessorKey
  accessorKey?: string;     // the row property this column reads
  header?: string | (() => string | Node);
  cell?: (ctx: { row: { original: TData }, getValue(): unknown }) => string | Node;
  enableSorting?: boolean;  // default true
  meta?: {
    width?: number;         // fixed px
    minWidth?: number;      // default 120 when no width is given
    align?: "left" | "center" | "right";
    hidden?: boolean;       // start hidden (still listed in the Columns menu)
    hideable?: boolean;     // default true — false pins it visible
    exportable?: boolean;   // default true
  };
}

Columns with the ids select and actions are treated as structural: never sorted, never exported, never listed in the Columns menu.

Custom cell renderers

Return a string for text, or a Node when you need markup:

{
  accessorKey: "status",
  header: "Status",
  cell: ({ row, getValue }) => {
    const badge = TableX.el("span", { class: "badge", text: String(getValue()) });
    badge.dataset.status = row.original.status;
    return badge;
  },
}

Two rules:

  • Return a new node on every call. The renderer runs once for the table row and once for the mobile card; handing back the same node would move it out of one and into the other.
  • Never build the node from an HTML string. el(), svgEl(), append() and replaceChildren() are exported for exactly this reason — they only ever write text through textContent. Row values passed to el({ text }) cannot be interpreted as markup, which is what keeps a name like <img onerror=…> a name.

Exports always use the underlying row value, not the rendered node: a custom cell is presentation, not data.


Theming

The stylesheet is one file of CSS custom properties. Re-skin the grid by overriding tokens — no class overrides, no !important:

.tbx-root {
  --tbx-primary: #7c3aed;
  --tbx-radius: 6px;
  --tbx-font: "Inter", system-ui, sans-serif;
}

| Token | Purpose | | --- | --- | | --tbx-font, --tbx-font-mono | Type stacks (mono is used for serial numbers). | | --tbx-bg, --tbx-card, --tbx-card-2 | Input, surface and header-row backgrounds. | | --tbx-border | Every border and divider. | | --tbx-fg, --tbx-muted, --tbx-muted-fg | Text, subtle fills, secondary text. | | --tbx-primary, --tbx-primary-fg | Accent: sort icons, current page, selection tint. | | --tbx-danger | Destructive accent. | | --tbx-radius, --tbx-radius-sm | Corner rounding. | | --tbx-shadow, --tbx-focus-ring | Elevation and the focus ring. |

Dark mode: pass theme: "dark" for an always-dark grid, theme: "auto" to follow the OS preference, or put .tbx-dark / .tbx-auto on any ancestor to switch several grids at once.

Density is a data attribute (data-density="compact|default|comfortable") on the root, so it can be styled or observed from outside the grid.


Accessibility

  • aria-label on the table, on the search field and on every icon-only control
  • aria-sort on sortable headers, which are focusable and respond to Enter/Space
  • role="menu" dropdowns with role="menuitemcheckbox" + aria-checked, arrow-key/Home/End roving focus, Escape to close (focus returns to the trigger) and outside-click to dismiss
  • aria-current="page" on the active pager button, visually hidden labels via .tbx-sr-only, and decorative SVGs marked aria-hidden
  • Focus survives re-renders: toolbar inputs are never rebuilt, and controls that are rebuilt (checkboxes, headers, pager buttons) are re-focused afterwards

Also exported

Because the browser bundle has no module system to reach the engine through, this package re-exports the parts of @nexgrid/core a page actually needs — defaultQuery, parseQuery, serializeQuery, buildQueryUrl, withPage, withSearch, withToggledSort, withSort, withPageSize, withFilter, getPageNumbers, getRecordRange, serialNumber, totalPagesFor, PAGE_SIZES, DENSITIES, DEFAULT_LOCALE, DEFAULT_BADGE_RULES, downloadCsv, downloadExcel, fetchAllPages and the DOM/icon helpers.

Mirroring the grid into the address bar is therefore three lines:

onQueryChange: (next) => {
  history.replaceState(null, "", "?" + TableX.serializeQuery(next));
}
// …and on load: query: TableX.parseQuery(location.search)

Author & Maintainer

Chhagan Sinha


License

MIT © 2026 Chhagan Sinha