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

@xsolla/xui-table

v0.159.0

Published

A composable, **strictly headless** data table that exposes only the structural primitives needed to render a table.

Readme

@xsolla/xui-table

A composable, strictly headless data table that exposes only the structural primitives needed to render a table.

The package ships only the structural primitives: container, caption, header, body, footer, rows, and cells (with sort + reveal-on-hover support). Everything else — title blocks, filter panels, item counters, bulk-selection bars, pagination, sorting, filtering, selection, empty/loading/error placeholders — is composed by the consumer. The toolkit doesn't care whether you use plain useState, TanStack Table, React Aria, Redux, or server components.

Designed against the Xsolla UI Kit Table.

Surface

<Table>
  <Table.Caption />     {/* optional — <caption>-equivalent */}
  <Table.Header>        {/* <thead>-equivalent (sticky) */}
    <Table.Row>
      <Table.Head />     {/* <th>-equivalent (sortable) */}
    </Table.Row>
  </Table.Header>
  <Table.Body>           {/* <tbody>-equivalent */}
    <Table.Row>
      <Table.Cell />
    </Table.Row>
  </Table.Body>
  <Table.Footer />       {/* slot for pagination etc. */}
</Table>

| Sub-component | Role | Notes | |---|---|---| | Table | role="table" container | Padding, radius, gap, theme | | Table.Caption | Caption / description | Goes anywhere inside <Table>; muted text by default | | Table.Header | role="rowgroup" (sticky) | Wraps a single <Table.Row> containing <Table.Head> cells | | Table.Body | role="rowgroup" | Wraps body <Table.Row> nodes; optional minRows prop reserves a stable height for paginated tables (see "Stable height across pages") | | Table.Footer | Pagination slot | Layout primitive only — drop in <Pagination>, <ProgressStep>, or your own | | Table.Row | role="row" | Used inside both Header and Body; auto-detects context | | Table.Head | role="columnheader" with aria-sort | Sortable via sort + onSortToggle | | Table.Cell | role="cell" | Optional revealOnHover for action cells |

Density is single-source from Figma — there's no size prop. All spacing, row heights, and font sizes come from the theme.sizing.table token. Figma node 22969:49282 only ships one canonical density (56 px rows, 24 px horizontal padding, 14 px body font); if denser variants are added later, this will become a size prop.

What's NOT in the toolkit (and why)

| Pattern | Where it lives | Why | |---|---|---| | Title block (<h1> + description + actions) | Consumer | Every team's design system has its own typography scale and action layout | | Filter panel (search + selects + view toggle) | Consumer | Filters change per page; coupling them to <Table> adds a config surface for no benefit | | Item counter ("N items") | Consumer | One <Text aria-live="polite"> line — no need for a sub-component | | Bulk-selection bar ("N selected" + actions) | Consumer | Mounted above <Table> when selected.size > 0; see the Selectable story | | Pagination | @xsolla/xui-pagination (numeric) or @xsolla/xui-stepper (<ProgressStep> for dots) — drop into <Table.Footer> | Already separate components | | Sorting / filtering / paging logic | Consumer (plain useState, TanStack Table, …) | Headless contract | | Empty / loading / error placeholders | Consumer renders inside <Table.Body> | Every team wants different illustrations / copy / CTAs |

The philosophy is a tiny primitive surface and infinite composition on the consumer side.

Headless contract

The component is fully controlled. None of the following is stored internally — the consumer passes it every render:

| Prop | On | What you control | |---|---|---| | sort="ascending" \| "descending" \| "none" | Table.Head | Active sort direction for this column. Renders the symmetric dual-chevron Sort icon — full opacity when sorted ("ascending" / "descending"), 40% when sortable but inactive ("none"). The icon itself doesn't visually distinguish ascending from descending (Figma's spec); direction is announced to assistive tech via aria-sort (set automatically). | | onSortToggle | Table.Head | Click handler for the sort indicator | | selected | Table.Row | Whether the row is in the selected state | | onPress | Table.Row | Row click handler | | hideDivider | Table.Row | Suppress the bottom divider (typically on the last row) | | revealOnHover | Table.Cell | Hide the cell until the parent row is hovered/focused |

Quick start

import { useState, useMemo } from "react";
import { Table } from "@xsolla/xui-table";
import { Pagination } from "@xsolla/xui-pagination";
import { Tag } from "@xsolla/xui-tag";

function PromotionsTable({ rows }) {
  const [sortKey, setSortKey] = useState("title");
  const [sortDir, setSortDir] = useState("ascending");
  const [page, setPage] = useState(1);

  const sorted = useMemo(() => {
    if (sortDir === "none") return rows;
    return [...rows].sort((a, b) =>
      sortDir === "ascending"
        ? a[sortKey].localeCompare(b[sortKey])
        : b[sortKey].localeCompare(a[sortKey])
    );
  }, [rows, sortKey, sortDir]);

  const cycleSort = (key) => {
    if (sortKey !== key) {
      setSortKey(key);
      setSortDir("ascending");
    } else if (sortDir === "ascending") setSortDir("descending");
    else if (sortDir === "descending") setSortDir("none");
    else setSortDir("ascending");
  };

  return (
    <Table>
      <Table.Header>
        <Table.Row>
          <Table.Head
            sort={sortKey === "title" ? sortDir : "none"}
            onSortToggle={() => cycleSort("title")}
          >
            Title
          </Table.Head>
          <Table.Head width={140}>Status</Table.Head>
          <Table.Head width={140}>Created</Table.Head>
        </Table.Row>
      </Table.Header>
      <Table.Body>
        {sorted.map((row, i) => (
          <Table.Row key={row.id} hideDivider={i === sorted.length - 1}>
            <Table.Cell>{row.title}</Table.Cell>
            <Table.Cell width={140}>
              <Tag tone={row.tone} size="sm">
                {row.status}
              </Tag>
            </Table.Cell>
            <Table.Cell width={140}>{row.created}</Table.Cell>
          </Table.Row>
        ))}
      </Table.Body>
      <Table.Footer>
        <Pagination
          currentPage={page}
          totalPages={5}
          onPageChange={setPage}
        />
      </Table.Footer>
    </Table>
  );
}

Plugging in TanStack Table (or any other engine)

@xsolla/xui-table does not depend on TanStack — it's a consumer choice. The toolkit renders rows; TanStack tells you which rows to render.

import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
} from "@tanstack/react-table";

function TanStackPromotionsTable({ data, columns }) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  return (
    <Table>
      <Table.Header>
        {table.getHeaderGroups().map((hg) => (
          <Table.Row key={hg.id}>
            {hg.headers.map((h) => (
              <Table.Head
                key={h.id}
                sort={h.column.getIsSorted() || "none"}
                onSortToggle={h.column.getToggleSortingHandler()}
              >
                {flexRender(h.column.columnDef.header, h.getContext())}
              </Table.Head>
            ))}
          </Table.Row>
        ))}
      </Table.Header>
      <Table.Body>
        {table.getRowModel().rows.map((row, i, all) => (
          <Table.Row key={row.id} hideDivider={i === all.length - 1}>
            {row.getVisibleCells().map((cell) => (
              <Table.Cell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </Table.Cell>
            ))}
          </Table.Row>
        ))}
      </Table.Body>
    </Table>
  );
}

Composing surrounding UI on the consumer side

Most production tables wrap extra UI around the data: a title block, a filter panel, an item counter, a bulk-selection bar, and pagination. The toolkit ships none of those — they're plain JSX. There are two valid positions for this surrounding UI:

A. Inside <Table>, above <Table.Header> (matches Figma)

The Figma "Filter panel" group sits inside the same rounded card as the header and rows. Achieve this by dropping the surrounding UI as direct children of <Table>.

<Table> ships flush-stack defaults (containerPaddingVertical: 0, containerGap: 0) — its rowgroups (Header / Body / Footer) sit edge-to-edge inside the card and the dividers do the visual separation. Figma's master shows 24 px top/bottom padding and a 16 px gap between groups, but that spacing only exists to host the filter-panel / counter blocks; reusing it for rowgroups produces visible gaps between Header and Body that look broken.

So each surrounding block composes its own spacing: wrap them in a column-flex <Box> with gap={16}, give the wrapper paddingTop={24} so it doesn't sit flush against the rounded card top edge, and have each block use paddingHorizontal={24} to align with header / cell padding. The Footer adds paddingTop={16} + paddingBottom={24} for the same reason at the bottom.

<Table>
  {/* Wrapper owns gap + top padding */}
  <Box flexDirection="column" gap={16} paddingTop={24}>
    {/* Title block */}
    <Box flexDirection="row" justifyContent="space-between" paddingHorizontal={24}>
      <Box flexDirection="column" gap={4}>
        <Text fontSize={24} fontWeight="600">Promotions</Text>
        <Text fontSize={14} style={{ opacity: 0.6 }}>
          Manage active and draft promotions
        </Text>
      </Box>
      <Box flexDirection="row" gap={8}>
        <Button variant="secondary">Export</Button>
        <Button>New promotion</Button>
      </Box>
    </Box>

    {/* Filter panel */}
    <Box flexDirection="row" paddingHorizontal={24}>
      <Input placeholder="Search" iconLeft={<Search size={18} />} />
    </Box>

    {/* Item counter */}
    <Box flexDirection="row" justifyContent="space-between" paddingHorizontal={24}>
      <Text aria-live="polite" style={{ opacity: 0.6 }}>{rows.length} items</Text>
      <FlexButton variant="tertiary" size="sm" iconRight={<Reset size={14} />}>
        Reset filters
      </FlexButton>
    </Box>
  </Box>

  <Table.Header>
    <Table.Row>
      <Table.Head>Title</Table.Head>
      ...
    </Table.Row>
  </Table.Header>
  <Table.Body>
    {rows.map((row) => (
      // No `hideDivider` on the last row — the divider visually
      // separates the body from the footer below.
      <Table.Row key={row.id}>...</Table.Row>
    ))}
  </Table.Body>
  <Table.Footer paddingTop={16} paddingBottom={24}>
    <Pagination ... />
  </Table.Footer>
</Table>

<Table.Header> paints a horizontal divider below itself, so the filter row above is visually separated from the column-header row without the consumer needing to add their own divider.

B. Outside <Table>, above the card

Useful when the bulk-selection bar should appear above the rounded card (common for "X selected" toolbars that visually replace a page-level header):

<Box flexDirection="column" gap={12}>
  {selected.size > 0 && (
    <Box role="region" aria-live="polite" /* …action layout… */>
      <Text>{selected.size} selected</Text>
      <Button>Delete</Button>
    </Box>
  )}
  <Table>
    <Table.Header>...</Table.Header>
    <Table.Body>...</Table.Body>
  </Table>
</Box>

See the Selectable story.

Pagination — pick the right component

Drop either of these into <Table.Footer>:

| Component | Use when | |---|---| | <Pagination> | Numeric pages — Prev / 1 2 3 … / Next | | <ProgressStep> (from @xsolla/xui-stepper) | Dot-style "step N of M" pagination |

<Pagination> is 1-indexed (currentPage, totalPages); <ProgressStep> is 0-indexed (activeStep, count). When swapping between them, adjust by ±1.

Stable height across pages (Table.Body minRows)

Paginated tables have three states that naturally render at different heights and cause the card to jump:

  1. Full pagespageSize rows, divider stack between them.
  2. Partial last page — fewer than pageSize rows.
  3. No-results state — a single empty-state block.

Pass minRows={pageSize} to <Table.Body> and the body locks itself to the full page height for all three:

<Table>
  <Table.Header>{/* … */}</Table.Header>
  <Table.Body minRows={pageSize}>
    {rows.length === 0 ? (
      <Box style={{ flex: 1, /* …centered no-results message… */ }}>
        No results
      </Box>
    ) : (
      rows.map((row, i) => (
        <Table.Row key={row.id} hideDivider={i === rows.length - 1}>
          {/* …cells… */}
        </Table.Row>
      ))
    )}
  </Table.Body>
  <Table.Footer>{/* …pagination… */}</Table.Footer>
</Table>

Behavior:

  • The body reserves a min-height of minRows × rowHeight + (minRows - 1) × dividerHeight from theme.sizing.table.
  • When all children are <Table.Row> and there are fewer of them than minRows, invisible placeholder slots are appended to fill the difference. The last real row keeps its divider visible (mirroring how a fully filled page looks); the last placeholder slot drops the +1px divider so the body bottom matches a full page exactly.
  • When the body holds a non-row child (e.g. your custom no-results panel), no placeholders are appended — only the min-height is enforced, so you can stretch the panel via flex: 1 or height: 100%.

Always render <Table.Footer> in the same conditional state, otherwise the footer disappearing on the no-results state will reintroduce a height jump.

Hide row actions until hover (revealOnHover)

<Table.Row>
  <Table.Cell>{row.title}</Table.Cell>
  {/* …other cells… */}
  <Table.Cell width={48} position="right" revealOnHover>
    <IconButton icon={<MoreVr />} aria-label={`Actions for ${row.title}`} />
  </Table.Cell>
</Table.Row>

The cell is hidden via opacity: 0 + pointer-events: none (not visibility: hidden), so keyboard Tab still focuses descendants. Focusing a descendant flips the row's focus-within state and reveals the cell. On touch-only devices the cell is always visible — matches Figma's "Right cell actions are always visible on touch" spec.

Truncation + tooltip on long values

<Table.Cell> and <Table.Head> wrap string children in a single-line <Text> with text-overflow: ellipsis. For long values, wrap them in a <Tooltip> so the full text is reachable:

<Table.Cell>
  <Tooltip content={row.title}>
    <span>{row.title}</span>
  </Tooltip>
</Table.Cell>

Loading: skeleton rows, not spinners

Render the same number of rows you expect to receive, with skeleton blocks inside each <Table.Cell>. Column widths stay fixed → no layout shift when real data arrives. See the LoadingState story.

Empty cells: aria-label for

<Table.Cell aria-label="No value">—</Table.Cell>

Keeps screen reader output meaningful.

Scrolling

The table is unopinionated about scroll. Wrap it (or the part you want to scroll) in a sized container.

Vertical scroll with sticky header

<Table.Header> already uses position: sticky; top: 0. Wrap <Table.Body> in a fixed-height overflow-y: auto container and the header stays pinned while rows scroll. See the Scrollable story.

Horizontal scroll

<Table> is intentionally opaque — it doesn't accept style or Box-level layout props, so the scroll viewport lives outside. Two-Box wrapper: the outer Box scrolls horizontally and the inner uses min-width: max-content so the table card grows to the natural width of its widest row.

<Box style={{ width: "100%", overflowX: "auto" }}>
  <Box style={{ minWidth: "max-content" }}>
    <Table>...</Table>
  </Box>
</Box>

Don't hard-code a pixel min-width. The actual row width is sum(cell widths) + (n - 1) × cellGap + 2 × rowPaddingHorizontal, which almost always undershoots if you eyeball it — and any miss leaves the cells spilling outside the card's white background when you scroll. max-content lets the browser compute the right value so the card and the cells stay the same width. See the HorizontalScroll story.

Frozen first column (optional)

Wrap the first cell in position: sticky; left: 0 with a matching background color and z-index. The toolkit doesn't ship a freezeFirst prop because the visual treatment (border, shadow, background) varies per team.

Virtualization

Not built in. Either:

  • Drop a virtualization wrapper around <Table.Body>'s children (@tanstack/react-virtual, react-window), or
  • Rely on server-side pagination via <Table.Footer> + <Pagination>.

Row height is fixed (theme.sizing.table.rowHeight, 56 px), so fixed-height virtualizers work out of the box.

Platform support

Web only. The package builds a dist/native/ bundle to match the workspace convention, but Table relies on behavior that doesn't exist on React Native (position: sticky, DOM mouse and focus events, CSS transitions). React Native is not supported.

For native screens, pair FlatList with your own row + cell components.

At a glance

| | @xsolla/xui-table | |---|---| | Surface | Table, Table.Caption, Table.Header, Table.Body, Table.Footer, Table.Row, Table.Head, Table.Cell | | State | None — bring your own (plain useState, TanStack Table, …) | | Sort indicator | Built into Table.Head (symmetric dual-chevron + aria-sort for direction) | | Hover-reveal cells | revealOnHover prop on Table.Cell | | Sticky header | Built into Table.Header | | Theming | Toolkit theme tokens (theme.sizing.table) | | Cross-platform | Web only | | Bundles a row data engine | No |