@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:
- Full pages —
pageSizerows, divider stack between them. - Partial last page — fewer than
pageSizerows. - 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-heightofminRows × rowHeight + (minRows - 1) × dividerHeightfromtheme.sizing.table. - When all children are
<Table.Row>and there are fewer of them thanminRows, 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+1pxdivider 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-heightis enforced, so you can stretch the panel viaflex: 1orheight: 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 |
