Item
One line in a list of things: media, content, actions.
src/components/rahti_ui/item.rs
Basic
Three regions across one line: media at the start, content in the middle, actions at the end. The actions are not pushed to the end by a `justify-between` or an `ml-auto` — the content column is `flex-1` and takes everything that is left, which is why a row with no content column has its actions sitting next to the title instead.
Choose what you are told about, and where.
Nothing is required. The second row has no media and no description, and the `gap-4` between the parts costs nothing for the ones left out.
Variants
Three, and all three are one border colour and one background. Nothing about a variant changes the row's size or its layout, which is what makes them safe to swap under a page's own state.
Transparent, and the same size as the other two.
A box of its own.
Filled, and still borderless.
A default row carries `border-transparent`, not nothing at all: the base sets a border *width*, and a width with no colour takes `currentColor`. The transparent one is what keeps all three exactly the same size.
shadcn writes that colour in the base and lets `twMerge` settle it against `outline`'s `border-border`. There is no Rust tailwind-merge, so this port moves the colour into the variants and joins instead — the loser is never written, and `audit::no_utility_contradicts_another` proves it stayed that way.
Sizes
Two. shadcn's Base UI style has a third, `xs`, whose design lives in marker classes an external stylesheet defines — this library has no such stylesheet and so does not carry it. `size="sm"` with a `.class(…)` is the way to a denser row.
`gap-4 p-4`.
`gap-2.5 px-4 py-3`.
Shorter, not narrower: the horizontal padding is `px-4` in both, and only the vertical shrinks. A list of rows keeps its left edge when a page switches density.
Media
Three variants, and the third is the interesting one: `default` is a box with no decoration at all, so the media slot is not limited to what this component knows how to draw. An Avatar, a Checkbox, a spinner — anything that is `shrink-0` and the right size.
A bordered `size-8` tile. The icon inside it is sized to `size-4` unless the icon says otherwise.
A `size-10` thumbnail. The source here is 96×32 and it is cropped rather than squashed — `[&_img]:object-cover`.

Draws nothing and holds whatever it is given. This is where an Avatar goes, which is what shadcn's own avatar example does.
Media placement
The only thing in this component that reads the row's contents, and it is one selector: `group-has-[[data-slot=item-description]]/item:self-start` on the media box. A row that is a single line centres its media; a row with a description under the title pins it to the top. Neither part is told about the other.
The tile is pinned to the top and nudged half a step down, so it lines up with the title's cap height rather than with its box. Long enough to wrap on a narrow screen, which is when the difference is obvious.
`:has` looks down rather than at a sibling, so the description can be nested however deep it likes and the media box still finds it. What it cannot survive is `unset("data-slot")` on the description — that attribute is the whole of the rule.
Group and separator
A `flex flex-col` and nothing else — the group has no padding, no border and no gap of its own, so the two examples above are the same component with two different `.class(…)`. Reach for a separator between default rows, which have nothing dividing them; between outline rows it is redundant, and a `gap-2` reads better.
The rule is `role="none"` by default, as every rule this library draws is: a line between two rows of one list is decoration, and a screen reader announcing each one interrupts the list. `announced` puts it back for a rule that divides one group of rows from another.
Header and footer
Two bands that span the row. They need no selector on either side and no cooperation from the parts between them — a row is `flex-wrap`, and something that asks for the full width takes a line.
Not shared with anyone yet.
Both are `basis-full` against the row's `flex-wrap`, which is the whole mechanism — the band fills a line and pushes everything else onto the next. It is also why the order matters: flex order is source order, and nothing here sorts the children. A footer written first is a header.
As a link
shadcn's `asChild`, without the child. There is no Slot in this port and nothing to take a tag from: `element` writes the anchor directly, and `href` implies it. The base class list was already written for this — two `[a]:` utilities that are dead on the `<div>` shadcn renders by default and come alive the moment the row is an anchor.
The whole row is the anchor. Hover it — that background is `[a]:hover:bg-accent/50`, which does nothing at all on a `<div>`.
`element="a"` written out, which `href` implies.
A row that is a link should hold no link of its own — the chevron above is an icon, not an anchor. Nested interactive content is the one thing this pattern gets wrong easily, and the browser will not stop you.
Two content columns
The second `ItemContent` does not grow — `[&+[data-slot=item-content]]:flex-none`, which is the only sibling selector in the component. That is how a row gets a right-hand column without an `ItemActions`: a price, a timestamp, a count.
Usage-based, billed monthly.
per seat
`+` reads the element immediately before, so the two columns have to be written next to each other with nothing between them. That is also the one case in this component where a fragment would be felt: a sibling that hydrates into a `<pp-fragment>` breaks the adjacency, and the second column starts growing again.
A real list
shadcn renders `<div role="list">` holding `<div>`s, which is a list role with no list items under it — announced as a list of nothing. `element="ul"` with `element="li"` rows is the version a screen reader can count, and it is the same class lists either way.
- First
- Second
- Third
`role="list"` is written on the `<ul>` too. Tailwind's preflight takes the marker off, and a list with no marker stops being announced as a list in Safari unless the role is put back.
An `ItemSeparator` inside a `<ul>` is not an `<li>` and the browser will re-parent it. Use the `<div role="list">` group when the stack is divided by rules, and this one when it is not.
Class override
The one thing a caller writes that can conflict with what the component chose. Everything else is joined — the component's layers never contradict each other, which is what the audit tests hold — so `class` is a finished string and the row is styled on arrival unless a page overrides it.
`p-6` beats `p-4`, `size-10` beats `size-8`, and `line-clamp-none` lets this description run to as many lines as it likes instead of being clipped at two. None of that is resolved on the server.
Look at the served HTML for that row: `class="{twMerge(…)}"`. There is no Rust tailwind-merge, so an override is literal text until PulsePoint mounts — and on a `flex-wrap` row that is a visible reflow rather than a colour arriving late. `item_variants(…)` on a native element is the way round it when the row must be right in the first byte.
The port
What came from where, and the one string that is not shadcn's.
The class strings are the published registry's — `new-york-v4` — as every component in this library is. shadcn ships this component twice, and the Base UI style writes the same design into `cn-item`-style marker classes that a separate stylesheet defines. There is no such stylesheet here.
Two things therefore live only in that style and are not here: a third size, `xs`, and a fourth media variant, `avatar`. Neither is lost — a denser row is `size="sm"` with a `.class(…)`, and an avatar row is an Avatar inside a default ItemMedia, which is what the registry's own example does and what the Media section above shows.
One string was changed. `border-transparent` is a variant layer here rather than a base one, because shadcn puts it in the base and leaves `twMerge` to settle it against `outline`'s `border-border`. Joining rather than merging means the loser is never written at all.
Two were added, and neither is a style: `element` on a group and on a row, so a stack can be a real `<ul>` of `<li>`s. shadcn's `asChild` makes that possible in React and this is the same thing by a shorter route.
The boundary
Where a PulsePoint expression written on an Item is compiled, and what to do when the answer is not the one you wanted.
This row came from `item().outline().render(…)`, interpolated into this page's block. No boundary, no script, and every part of it is a plain element in the page's own scope.
The rule is the same for every component here. A `#[component]` tag's root carries `pp-component` and is a scope, so a `{…}` written on the tag is evaluated where the tag was written. `class="{twMerge(…)}"` survives that — `twMerge` is a global — and `onclick={…}` does not.
So: tags for a static row, and `item()` plus `.render(…)` for one the page drives. The controlled example at the bottom of this page is the second kind, and the two content columns above are why it matters structurally as well — a part that hydrates into a `<pp-fragment>` stops being the sibling the `+` selector was looking for.
The other half of the same rule, and the reason the row above carries no `.class(…)`: a `render` writes no `<script>`, so an override on it resolves only if the block it was interpolated into has one. This section's body is a fragment with no script, so a merge written here would still be literal text in the browser. The controlled rows at the bottom of the page live in `page()`'s own block, which does have a script — theirs resolve.
Controlled, page scope
Not one tag in here. `<Item>` is a component, and a component is a PulsePoint boundary — a `{…}` written on its tag is resolved where the tag was written, which is right for a `class` override and wrong for a handler. So a row a page drives is built with `item()` and rendered into the page's own block, and so is every part inside it. Every `render` in this library carries no `<script>`, which is what keeps the whole row inside one scope.
Picked: {picked}
The bound attribute is `data-state`, not `data-selected`. A binding on any attribute whose name ends in an HTML boolean word — `open`, `disabled`, `selected` — is compiled as a boolean binding and has its name truncated when the value is falsy. `data-state` is Radix's own convention and it survives.