Table
The HTML table elements, with a class list on each.
src/components/rahti_ui/table.rs
Basic
A caption, a head and a body. The last row loses its bottom border, which is the whole of what `TableBody` does.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
The caption is written first and paints last. `<caption>` is only valid as the first child of `<table>` — put it anywhere else and the parser moves it for you — while `caption-bottom` on the table is what then drops it underneath. Both halves are needed and they point in opposite directions.
With a footer
A totals row. `<tfoot>` is a real element and belongs after the body in the markup, which is also where it renders.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Paid | PayPal | $550.00 |
| Total | $1,750.00 | ||
`colspan` of `0` means "no attribute", so one prop serves the footer's spanning cell and every ordinary one. The footer is told apart from the body by `border-t` and `bg-muted/50` rather than by weight alone, and `[&>tr]:last:border-b-0` cancels the line under its final row — the same edge the body handles from the other side.
Actions
A table showing actions for each row using a `<DropdownMenu />` component.
| Product | Price | Actions |
|---|---|---|
| Wireless Mouse | $29.99 | |
| Mechanical Keyboard | $129.99 | |
| USB-C Hub | $49.99 |
shadcn's `TableActions` demo, row for row. The trigger is a ghost icon button; upstream passes it through the trigger's `render` prop, and here the trigger simply wears the button's class list instead — a `<button>` nested in a `<button>` is not markup a browser will keep.
The menu name is per row, and it has to be: it becomes the popover's `id`, the trigger's `popovertarget` and the CSS anchor name, and all three are document-wide. Three rows sharing one name would give you three triggers opening the first row's menu.
This is also the section that would have caught the `<table>` defect on its own. A dropdown inside a cell is a nested component scope, so had the `<table>` carried a binding, the element around all of this would have been deleted on mount — see `The boundary` at the foot of the page.
The menu itself is in the top layer, so a table that scrolls or clips does not clip the menu — that is the popover API rather than anything this component does, and it is why no portal is needed here.
Row headers and scope
What this port adds over shadcn, and the argument for keeping both additions opt-in.
| Invoice | Status |
|---|---|
| INV001 | Paid |
| INV002 | Pending |
shadcn renders a bare `<th>` and no `scope` anywhere. A bare `<th>` is ambiguous — a screen reader has to guess whether it heads a column or a row — so this port takes `scope` on `TableHead` and lets `TableCell` render as a `<th>` for the row-header case. Neither is a default, and neither should be: `scope="row"` on a cell that is data rather than a label is a worse lie than no scope at all.
The `th` cell keeps the *cell's* class list, not the head's — `p-2 align-middle`, no `h-10`. It is a row header, not a column header that wandered into the body.
Selected, from the server
The one state a Table has. It is an attribute, and that is the whole interface.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
`selected` writes `data-state="selected"` and nothing else — no class, no script, no selection logic. `data-[state=selected]:bg-muted` on the row is the hook shadcn already ships, and this is a table whose selection was decided on the server. The last section on this page is the same attribute driven from the browser instead.
The container, and the prop React has no spelling for
Scroll the table below. The header stays because the wrapper got a height, and the wrapper got a height because there is a prop that reaches it.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Paid | PayPal | $550.00 |
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
| INV004 | Paid | Credit Card | $450.00 |
| INV005 | Paid | PayPal | $550.00 |
`Table` renders two elements: a `<div data-slot="table-container">` and the `<table>` inside it. In React both `className` and the props spread land on the `<table>`, so nothing a call site writes can reach the wrapper — which is fine until you want a sticky header, because `position: sticky` needs a scroll container with a height and the only element that could carry one is the element the component hid.
So `container_class` is the one prop this port invented. `class` still goes on the `<table>`, exactly as `className` does upstream; `container_class` goes on the `<div>`. `table_container_variants` is the same thing for a page writing the two elements itself.
Overflow
What the wrapper is for, in the case it was designed for.
| Invoice | Status | Method | Billing contact | Purchase order | Amount |
|---|---|---|---|---|---|
| INV001 | Paid | Credit Card | accounts.payable@example.com | PO-2026-00418 | $250.00 |
| INV002 | Pending | PayPal | accounts.payable@example.com | PO-2026-00418 | $150.00 |
| INV003 | Unpaid | Bank Transfer | accounts.payable@example.com | PO-2026-00418 | $350.00 |
The container is inside a `max-w-md`, and the table scrolls sideways rather than pushing the page out. That is `overflow-x-auto` on the wrapper, and it is why the wrapper exists at all: a `<table>` is the one block element that refuses to shrink below its content, so without a scroll container around it a wide table widens everything above it.
`whitespace-nowrap` on heads and cells is a real opinion and worth knowing about — a long cell widens the table and scrolls rather than wrapping. `.class("whitespace-normal")` on the cell is the override, and it settles in the browser like every other one.
Class overrides
Two elements, two class props, two merges.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
A bordered table is `container_class="rounded-md border"` — the border goes on the wrapper, not the table, or it would scroll away with the content. The `text-xs` here is on the `<table>`, and it is one `twMerge` against `text-sm` resolved when PulsePoint mounts.
Both overrides put the element's `class` through `twMerge` in the browser, which is this library's rule everywhere. That is why the two elements each get their own prop rather than sharing one: they are merged separately because they are separate elements.
The port
Where the class strings came from, and the one place the two sources disagree.
…[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]…[&:has([role=checkbox])]:pr-0The two sources agree on eight of the nine strings token for token. On `TableHead` and `TableCell` the registry nudges a checkbox down two pixels and the docs page does not, letting `align-middle` do the centring. The page wins, as it does everywhere in this library — and because the files are otherwise identical, this reads as a deliberate removal upstream rather than a registry a version behind.
One more thing the rendered page shows that neither published source does: some demos render `text-start` and `[&:has(…)]:pe-0` where the source says `text-left` and `pr-0`. shadcn is part-way through a move to logical properties. The published source is what someone copying off that page gets today, so it is what this file copies; when the migration lands, the change here is two tokens and one test.
The boundary
Two things a Rust port has to get right that React never had to think about: where a scope marker may sit inside a table, and which scope a row's binding is compiled in.
Every component in this library renders an empty `<script>` inside its root when something needs a mounted PulsePoint scope — a caller `class` going through `twMerge`, or any binding. Inside a table that is a question rather than a formality. The parser treats table internals as a special insertion mode and *foster-parents* what it does not expect: text and stray elements between a `<table>` and its rows are lifted out and re-inserted before the table.
`<script>` is one of the few things that survives. "in table", "in table body" and "in row" all route a `<script>` start tag through the "in head" rules, so it is inserted where it was written. `TableHeader`, `TableBody`, `TableFooter` and `TableRow` rely on exactly that.
`Table` does not need to rely on it and does not: its root is the container `<div>`, so its marker goes after the `</table>`, outside the table altogether. Two tests hold both placements in place, because a future edit that moved either one would fail in the browser and nowhere else.
The other half of the boundary is the scope rule, and it is why the last section on this page uses builders instead of tags. A `<Table>` is a component, so its children compile against its scope — a `picked` declared in the page's `<script>` is not in it. Builders render into the page's own block and carry no `<script>` of their own, so the whole table stays in one scope.
And one measured runtime defect this component is shaped around. Give a `<table>` any PulsePoint binding — a merged `class`, an `onclick`, a bound `data-*` — while its subtree holds a nested component scope, and the runtime deletes the `<table>` on mount. The `<thead>` and `<tbody>` survive, re-parented into the container and reordered, so every rule that depends on table layout stops applying. A `<Table class="max-w-md">` around a `<TableCell class="font-medium">` is enough.
It is specific to `<table>`: a bound `<thead>`, `<tbody>` or `<tr>` with the same nested scope inside is fine, and so is an unbound `<table>`. It is not the parser either — running the served HTML through `DOMParser` gives all eight tables on this page intact, scope markers and all.
So the component obeys one rule and the tests hold it: no attribute of the rendered `<table>` is ever a binding. Its `class` is joined on the server rather than merged in the browser, and a caller's bindings are moved to the container, which is the component's root and sees the same clicks anyway. The cost is that a `class` conflicting with `w-full`, `text-sm` or `caption-bottom` is written beside it rather than beating it — three utilities of exposure, and `container_class` is the better home for a width regardless. All of it is one commit to undo once a bound `<table>` survives.
Selected, page scope
Not one tag in here. `<Table>` is a component, and a component is a PulsePoint scope of its own — children handed to it are compiled against *its* scope, which has no `picked`. So a table a page drives is built with `table()` and rendered into the page's own block, and so is every row and every cell. Every `render` in this library carries no `<script>`, which is what keeps the whole table inside one scope.
| Invoice | Status | Amount |
|---|---|---|
| INV001 | Paid | $250.00 |
| INV002 | Pending | $150.00 |
| INV003 | Unpaid | $350.00 |
Picked: {picked}
The click handler and the `data-state` binding are on the row's own `<tr>`, which is what a `render` gives you — no `<pp-fragment>` between the row and its `<tbody>`, which in a table is not a cosmetic difference. An element the parser does not expect there is lifted out of the table entirely.
`data-state` rather than `selected`, and that is not a style preference. The browser runtime rewrites any binding whose attribute name ends in an HTML boolean-attribute word — `selected`, `open`, `disabled`, `checked` — into a boolean attribute, and truncates the name when the expression is falsy. `data-[state=selected]:bg-muted` is the hook shadcn already ships, so there is nothing to invent.