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

inu-tables

v0.0.1-alpha.1

Published

Data tables built for Svelte 5.

Readme

Inu Tables

Data tables built for Svelte 5.

A headless, reactive table state library for Svelte 5. Inu Tables manages sorting, filtering, pagination, column visibility, and row selection as plain reactive classes — you own the markup entirely.

  • Headless — no styles, no HTML generated. Render however you like.
  • Runes-native — state is $state/$derived; bind directly in templates.
  • Client or server — full client-side pipeline or delegate everything to the server.
  • TypeScript-first — all public APIs are fully typed; row shapes are generic.

Installation

npm install inu-tables
# or
pnpm add inu-tables

Svelte 5 is a peer dependency.


Quickstart — client-side table

1. Define your data shape and columns

// +page.svelte
import { TableState } from 'inu-tables';

type Person = {
	id: number;
	firstName: string;
	lastName: string;
	age: number;
	joined: Date;
};

const data: Person[] = [
	{ id: 1, firstName: 'Alice', lastName: 'Smith', age: 30, joined: new Date('2022-01-15') },
	{ id: 2, firstName: 'Bob', lastName: 'Jones', age: 25, joined: new Date('2023-06-01') },
	{ id: 3, firstName: 'Carol', lastName: 'Taylor', age: 35, joined: new Date('2021-11-30') }
];

const table = new TableState<Person>({
	data,
	columns: [
		{ accessorKey: 'firstName', header: 'First Name', sortable: true, filterable: true },
		{ accessorKey: 'lastName', header: 'Last Name', sortable: true, filterable: true },
		{ accessorKey: 'age', header: 'Age', sortable: true, filterable: true, filterType: 'number' },
		{
			accessorKey: 'joined',
			header: 'Joined',
			sortable: true,
			filterable: true,
			filterType: 'date'
		}
	],
	pageSize: 10
});

Use accessorKey to point at a key of your row type (column id defaults to the key name). For computed or combined fields, use accessorFn with an explicit id instead:

{
  id: 'fullName',
  header: 'Full Name',
  accessorFn: (r) => `${r.firstName} ${r.lastName}`,
  sortable: true,
}

2. Render the table

<script lang="ts">
	// ... table setup from above ...

	// Keep page in sync when a filter changes
	$effect(() => {
		for (const col of table.columns) col.filterValue;
		table.setPage(0);
	});

	// Drive the indeterminate state of the select-all checkbox
	let selectAllEl = $state<HTMLInputElement | null>(null);
	$effect(() => {
		if (selectAllEl) selectAllEl.indeterminate = table.someSelected;
	});
</script>

<table>
	<thead>
		<!-- Sort headers -->
		<tr>
			<th>
				<input
					type="checkbox"
					bind:this={selectAllEl}
					checked={table.allSelected}
					onchange={(e) => table.selectAll((e.target as HTMLInputElement).checked)}
				/>
			</th>
			{#each table.visibleColumns as col (col.id)}
				<th aria-sort={table.getSortDirection(col)}>
					{#if col.sortable}
						<button onclick={() => table.toggleSort(col)}>{col.header}</button>
					{:else}
						{col.header}
					{/if}
				</th>
			{/each}
		</tr>
		<!-- Filter inputs -->
		<tr>
			<td></td>
			{#each table.visibleColumns as col (col.id)}
				<td>
					{#if col.filterable}
						<input
							type={col.filterType === 'number'
								? 'number'
								: col.filterType === 'date'
									? 'date'
									: 'text'}
							bind:value={col.filterValue as string}
							placeholder="Filter…"
						/>
					{/if}
				</td>
			{/each}
		</tr>
	</thead>
	<tbody>
		{#each table.paginatedRows as row (row)}
			<tr>
				<td>
					<input type="checkbox" checked={row.selected} onchange={() => table.selectRow(row)} />
				</td>
				{#each table.getCellsForRow(row).filter((c) => c.column.show) as cell (cell.column.id)}
					<td>{cell.value}</td>
				{/each}
			</tr>
		{/each}
	</tbody>
</table>

<!-- Pagination -->
<button onclick={() => table.prevPage()} disabled={table.pageIndex === 0}>Prev</button>
<span>Page {table.pageIndex + 1} of {table.pageCount}</span>
<button onclick={() => table.nextPage()} disabled={table.pageIndex >= table.pageCount - 1}
	>Next</button
>

3. Column visibility toggles

col.show is plain $state(true) — bind to a checkbox to show/hide columns:

{#each table.columns as col (col.id)}
	<label>
		<input type="checkbox" bind:checked={col.show} />
		{col.header}
	</label>
{/each}

Quickstart — server-side table

Use ServerTableState when filtering, sorting, and pagination happen on the server. The state class fires a fetch automatically whenever any table parameter changes.

1. Write the server fetch function

With SvelteKit remote functions (recommended):

// data.remote.ts
import { query } from '$app/server'
import type { ServerTableParams, ServerTableResult } from 'inu-tables'

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

const db: Person[] = /* your data source */[]

export const getPersons = query(
  /* optional Zod schema for params */,
  async (params: ServerTableParams): Promise<ServerTableResult<Person>> => {
    let data = db

    // Filter
    if (params.filters['firstName']) {
      const q = String(params.filters['firstName']).toLowerCase()
      data = data.filter(r => r.firstName.toLowerCase().includes(q))
    }
    if (params.filters['age']) {
      const min = Number(params.filters['age'])
      data = data.filter(r => r.age >= min)
    }

    // Sort
    if (params.sortBy) {
      const { id, direction } = params.sortBy
      const mul = direction === 'ascending' ? 1 : -1
      data = [...data].sort((a, b) => {
        const av = a[id as keyof Person]
        const bv = b[id as keyof Person]
        return (av < bv ? -1 : av > bv ? 1 : 0) * mul
      })
    }

    // Paginate
    const rowCount = data.length
    const start = params.pageIndex * params.pageSize
    return { rows: data.slice(start, start + params.pageSize), rowCount }
  }
)

Any (params: ServerTableParams) => Promise<ServerTableResult<TRow>> function works — REST endpoints, tRPC procedures, SvelteKit load functions, etc.

2. Create the table state

// +page.svelte
import { ServerTableState } from 'inu-tables';
import { getPersons } from './data.remote.ts';

const table = new ServerTableState<Person>({
	fetch: getPersons,
	columns: [
		{ accessorKey: 'firstName', header: 'First Name', sortable: true, filterable: true },
		{ accessorKey: 'age', header: 'Age', sortable: true, filterable: true, filterType: 'number' }
	],
	pageSize: 20
});

An initial fetch fires automatically when the component mounts.

3. Render with loading and error states

ServerTableState exposes loading and error alongside the same sort/filter/pagination API as TableState:

{#if table.error}
  <p>Error: {table.error.message}</p>
{/if}

{#if table.loading}
  <p>Loading…</p>
{/if}

<table>
  <thead>
    <tr>
      {#each table.visibleColumns as col (col.id)}
        <th aria-sort={table.getSortDirection(col)}>
          <button onclick={() => table.toggleSort(col)}>{col.header}</button>
        </th>
      {/each}
    </tr>
    <tr>
      {#each table.visibleColumns as col (col.id)}
        <td>
          {#if col.filterable}
            <input
              bind:value={col.filterValue as string}
              oninput={() => table.setFilter(col, col.filterValue)}
              placeholder="Filter…"
            />
          {/if}
        </td>
      {/each}
    </tr>
  </thead>
  <tbody>
    {#each table.rows as row (row)}
      <tr>
        {#each table.getCellsForRow(row).filter(c => c.column.show) as cell (cell.column.id)}
          <td>{cell.value}</td>
        {/each}
      </tr>
    {/each}
  </tbody>
</table>

<button onclick={() => table.prevPage()}>Prev</button>
<span>Page {table.pageIndex + 1} of {table.pageCount} — {table.rowCount} results</span>
<button onclick={() => table.nextPage()}>Next</button>

Use table.setFilter(col, value) instead of setting col.filterValue directly — it also resets pageIndex to 0, which is almost always the right behaviour for server-side filtering.


API reference

TableState<TRow>

Client-side table. All filtering, sorting, and pagination run in the browser.

| Member | Type | Description | | ----------------------- | --------------------------------------- | ------------------------------------------------- | | columns | ColumnState<TRow>[] | All columns, definition order. | | rows | RowState<TRow>[] | All rows, data order. | | cells | CellState<TRow>[] | All cells, row-major order. | | sortBy | { column, direction } \| null | Active sort, or null. | | pageIndex | number | Current zero-based page. | | pageSize | number | Rows per page (default 10). | | filteredRows | RowState<TRow>[] | Rows passing all active filters. | | sortedRows | RowState<TRow>[] | Filtered rows after sort. | | paginatedRows | RowState<TRow>[] | Current page slice. | | pageCount | number | Total pages (min 1). | | visibleColumns | ColumnState<TRow>[] | Columns where show is true. | | visibleCells | CellState<TRow>[] | Cells for visible columns on current page. | | allSelected | boolean | true when every row is selected. | | someSelected | boolean | true when some but not all rows are selected. | | toggleSort(col) | void | Cycle sort: none → ascending → descending → none. | | getSortDirection(col) | 'ascending' \| 'descending' \| 'none' | aria-sort-compatible value. | | clearFilters() | void | Clear all column filters, reset page to 0. | | selectRow(row) | void | Toggle a single row's selection. | | selectAll(selected) | void | Select or deselect all rows. | | nextPage() | void | Advance one page (no-op at last page). | | prevPage() | void | Go back one page (no-op at first page). | | setPage(index) | void | Navigate to a page, clamped to valid range. | | getCellsForRow(row) | CellState<TRow>[] | O(1) cell lookup for a row. |

ServerTableState<TRow>

Server-side table. Identical API to TableState plus:

| Member | Type | Description | | ----------------------- | --------------- | -------------------------------------------- | | loading | boolean | true while a fetch is in flight. | | error | Error \| null | Error from the last failed fetch, or null. | | rowCount | number | Total matching rows reported by the server. | | setFilter(col, value) | void | Set a filter value and reset page to 0. |

rows and cells contain only the current page (not the full dataset).

ColumnState<TRow>

| Member | Type | Description | | ------------- | -------------------------------------- | ----------------------------------------- | | id | string | Column identifier. | | header | string | Display label. | | accessor | (row: TRow) => unknown | Value extractor. | | sortable | boolean | Whether this column can be sorted. | | filterable | boolean | Whether this column can be filtered. | | filterType | 'text' \| 'number' \| 'date' | Active filter strategy. | | show | $state boolean | Column visibility (bind to a checkbox). | | filterValue | $state string \| number \| undefined | Current filter value (bind to an input). | | isFiltered | $derived boolean | true when a non-empty filter is active. |

RowState<TRow>

| Member | Type | Description | | ---------- | ---------------- | ----------------------------- | | data | TRow | The original row data object. | | selected | $state boolean | Selection state. |

CellState<TRow>

| Member | Type | Description | | -------- | ------------------- | -------------------------------------------------- | | row | RowState<TRow> | The row this cell belongs to. | | column | ColumnState<TRow> | The column this cell belongs to. | | value | unknown | Computed cell value (column.accessor(row.data)). |

Column definitions

Two variants, both extend a common base:

// Key variant — id defaults to accessorKey
{ accessorKey: 'age', header: 'Age', sortable: true }

// Function variant — id required
{ id: 'fullName', header: 'Full Name', accessorFn: (r) => `${r.first} ${r.last}` }

Common options:

| Option | Type | Default | Description | | ------------ | ------------------------------------- | -------- | ------------------------- | | header | string | — | Column label. | | sortable | boolean | false | Enable sorting. | | sortFn | (a, b) => number | built-in | Custom sort comparator. | | filterable | boolean | false | Enable filtering. | | filterType | 'text' \| 'number' \| 'date' | 'text' | Built-in filter strategy. | | filterFn | (cellValue, filterValue) => boolean | built-in | Custom filter function. |

Built-in filter functions

| Function | Behaviour | | -------------- | --------------------------------------------------------- | | textFilter | Case-insensitive string containment. | | numberFilter | Show rows where cell value >= filter value. | | dateFilter | Show rows where cell date is on or after the filter date. |

All three treat undefined, null, and '' as "no filter" (pass all rows). Import them directly if you need them outside of column definitions:

import { textFilter, numberFilter, dateFilter } from 'inu-tables';

ServerTableParams

Passed to your fetch function on every state change:

interface ServerTableParams {
	pageIndex: number;
	pageSize: number;
	sortBy: { id: string; direction: 'ascending' | 'descending' } | null;
	filters: Record<string, string | number | undefined>;
}

ServerTableResult<TRow>

What your fetch function must return:

interface ServerTableResult<TRow> {
	rows: TRow[]; // current page only, already filtered + sorted
	rowCount: number; // total matching rows (before pagination)
}

License

MIT