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

@asnewyla/unstyled-table

v0.2.0

Published

Unstyled table primitive with sorting, row selection, and pagination

Downloads

123

Readme

@asnewyla/unstyled-table

Unstyled, accessible table primitive. Renders rows and columns from plain data, with single-column sorting, client-side global search, row selection, and pagination, without imposing any visual styling.

Install

npm install @asnewyla/unstyled-table

Usage

import { UnstyledTable } from '@asnewyla/unstyled-table';

interface Person {
  name: string;
  age: number;
}

const people: Person[] = [
  { name: 'Ada Lovelace', age: 36 },
  { name: 'Alan Turing', age: 41 },
];

const columns = [
  { key: 'name', header: 'Name', sortable: true },
  { key: 'age', header: 'Age' },
];

// Plain rendering
<UnstyledTable data={people} columns={columns} />

// With a search box (client-side, filters across every column's value)
<UnstyledTable data={people} columns={columns} filterable />

// Controlled sort
<UnstyledTable
  data={people}
  columns={columns}
  sort={sort}
  onSortChange={setSort}
/>

// A custom cell renderer per column
const columnsWithRender = [
  { key: 'name', header: 'Name' },
  { key: 'age', header: 'Age', render: (row) => `${row.age} yrs` },
];

// Row selection (controlled) — see "Row identity" below for getRowKey
<UnstyledTable
  data={people}
  columns={columns}
  getRowKey={(row) => row.id}
  selectable
  selected={selected}
  onSelectionChange={setSelected}
/>

// Pagination — fixed page size, no built-in page-size selector
<UnstyledTable
  data={people}
  columns={columns}
  paginated
  pageSize={10}
/>

Props

| Prop | Type | Default | |---|---|---| | columns | TableColumn<T>[] | — | | data | T[] | — | | sort / defaultSort | { key: keyof T; direction: 'asc' \| 'desc' } \| null | — | | onSortChange | (sort: SortState<T> \| null) => void | — | | filterable | boolean | false | | getRowKey | (row: T) => RowId (RowId = string \| number) | — | | selectable | boolean | false | | selected / defaultSelected | RowId[] | — | | onSelectionChange | (selected: RowId[]) => void | — | | paginated | boolean | false | | pageSize | number | 10 | | page / defaultPage | number | — | | onPageChange | (page: number) => void | — |

Also accepts every native <table> attribute (className, style, id, etc.) via passthrough — UnstyledTable composes ComponentPropsWithoutRef<'table'> like every other primitive in this library.

TableColumn<T>:

| Field | Type | Default | |---|---|---| | key | keyof T | — | | header | string | — | | render | (row: T) => ReactNode | — | | sortable | boolean | false |

Uncontrolled by default — pass sort + onSortChange to control the sort state yourself. render overrides how a cell displays a column's value; without it, a cell just shows String(row[column.key]).

Sorting

Only one column can be sorted at a time. Clicking a sortable column's header cycles ascending → descending → unsorted; clicking a different sortable column resets to ascending on that column instead. Non-sortable columns render as plain <th> text — no button, no aria-sort.

Filtering

filterable renders a single <input aria-label="Search table"> above the table. Typing filters data client-side by a case-insensitive substring match against every column's raw value (row[column.key], not its rendered output) — there's no async/remote search, matching the same client-side-only scope as @asnewyla/unstyled-select's search. Filtering is always applied before sorting.

Row identity and selection

getRowKey gives each row a stable identity — used both for React's own key (so a row's DOM state, like an uncontrolled input inside a custom render, stays attached to that row across a sort/filter reorder) and for tracking selected. Without it, identity falls back to JSON.stringify(row), which works as long as rows are unique by content.

selectable adds a checkbox column: a per-row checkbox plus a "select all" checkbox in the header (aria-label="Select all rows", native indeterminate when some but not all currently rendered rows are selected). UnstyledTable never mutates data — deleting/exporting/ acting on a selection is entirely the consumer's job, using selected + whatever action they wire up next to the table (see the BulkDeleteSelectedRows Storybook story).

Pagination

paginated slices the final (filtered, then sorted) row set into pages of pageSize rows and renders a footer below the table: four buttons — "First page", "Previous page", "Next page", "Last page" (plain text content, which doubles as the accessible name) — around a plain-text "Page X of Y" indicator. First/Previous are disabled on the first page, Next/Last on the last page, via native disabled rather than just visual styling. Each button also carries data-pagination-action="first" | "previous" | "next" | "last", a stable hook for consumers to style them (e.g. as icon buttons) without depending on their text content. There's no built-in page-size selector; pageSize is a fixed prop. The current page clamps automatically if the underlying row count shrinks out from under it (e.g. filtering down to fewer rows/pages than the page you were on) — this happens on every render as a plain derived value, not via an effect, so it can't ever momentarily show a page that no longer exists.

Accessibility

Renders a real semantic <table>/<thead>/<tbody> — row/column-header/ cell roles all come from the native elements, not ARIA overrides. Sortable headers use a real <button> (keyboard-reachable, Enter/Space activates for free) with aria-sort on the parent <th> reflecting the current state ("ascending", "descending", or "none").

Not yet supported

This primitive is still growing. Not implemented yet:

  • Native form participation — nothing here submits through FormData.

License

MIT