Button Group
A container that welds related controls into one.
src/components/rahti_ui/button_group.rs
Basic
Three utilities and no props. The group squares every inner edge and drops the left border that would otherwise double up against its neighbour's right one — `[&>*:not(:first-child)]` and `[&>*:not(:last-child)]`, applied to whatever it was given. There is nothing here about buttons: a Button, an Input, a Select and a ButtonGroupText are all welded by the same three lines, which is why this port needs no context and no per-child props.
The outline row is where the welding shows: every child but the first has `border-l-0`, so two adjacent borders are drawn as one line, and only the outer corners keep their radius.
Orientation
The same three utilities turned ninety degrees, plus the `flex-col` that turns the box: `rounded-t-none`, `border-t-0`, `rounded-b-none`. The row's utilities are not overridden in a column — they are never joined in, so there is nothing left to beat and the class list is exact on the server.
`data-orientation` is written on every group here, where shadcn leaves it off a group whose prop was never passed. Nothing selects on it — the orientation is a class layer — so it is one more thing a page can read and one less thing that depends on how the group was built.
Sizes
A size is the Button's, not the group's — there is no `size` prop here at all. `items-stretch` is what keeps a mixed row honest: whatever the tallest child decides, the rest match, which is also what lets a separator be a full-height rule without being told how tall the buttons are.
Separator
Reach for one between `default`-variant buttons, which have no border of their own to divide them. Between `outline` buttons it is redundant — their welded borders already draw that line, which is what shadcn's own documentation says about it. The rule takes `bg-input` rather than the Separator's `bg-border`: it is continuing the buttons' edges, not dividing the page.
`group.separator()` is the one piece of context a Button Group has to hand over: a rule runs across the strip, so a row's separator is vertical and a column's is horizontal. The third example above is the builder form of the second, and it is the one that never has to be told twice.
Split button
One action and its menu, welded into a single control. The pattern is the whole reason the separator exists: the two halves are one object, so they share an outline, and the rule is what tells a reader where the click stops being "Send" and starts being "choose how".
The rule inside a split button is decoration rather than a division between two things, so the second one here gives up its announcement with `unset("role")` — the thing a list of attribute pairs cannot say, and the reason the forwarded prop is an `Attrs`.
Text
The third part, and the only one that draws anything of its own. It is a `<div>` by default; `element="label"` is shadcn's `asChild`, for the one case where the welded label has to focus the control beside it.
A ButtonGroupText is shaped like a `secondary` button that cannot be pressed — the same border, radius, text size and icon sizing, over `bg-muted`. The group then welds it like anything else, and the strip reads as one object with a label built into it.
With an input
A label welded to its field, and a field welded to its button. The label is a real `<label for=…>`, which is what shadcn's `asChild` was for — a `<div>` here would look identical and focus nothing.
`[&>input]:flex-1` is what makes the field take the leftover width instead of its own — an Input's `w-full` would otherwise fight the group's `w-fit`. The group is `w-fit` by default here too, so both examples carry a `class` override to widen it, which is the one thing that hands the class list to `twMerge` in the browser.
With a select
Nothing about a Select is special to the group — it is a child, it has a border and a radius, and the same three utilities square the edge that faces its neighbour.
Two utilities in the group's base are about Radix's Select — a trigger button with a hidden native `<select>` beside it — and neither can match here, because this library's Select *is* the native element. They are carried unchanged rather than quietly dropped, and nothing is lost by it: a Select already carries `w-fit` in its own base, which is all the second rule was for.
Nested
A group that contains groups stops welding and starts spacing. The inner groups still weld their own children, so the toolbar reads as three controls rather than as five buttons or as one.
`has-[>[data-slot=button-group]]:gap-2` is how a group notices it is holding other groups. That is the one thing `data-slot` is load-bearing for in this component — everything else selects structurally.
Class override
`class` is the one thing that can conflict with what a component chose, so it is the one thing resolved in the browser: `class` becomes `{twMerge(…)}`, literal text until the runtime mounts. With no override the class list is a finished string and the group is styled by the server, JavaScript or no JavaScript.
Three overrides here and only one of them is free. The group's own is: it renders as a fragment, so a `<pp-fragment>` ends up between it and this box — which costs the group its position as a direct child of something else, and costs its own children nothing. The buttons' are not, which is why both are written as builders: a `<Button class="flex-1">` would put that wrapper *inside* the group, where it is the thing the welding rules select and the button is not.
The port
shadcn ships this component twice: a Base UI style whose design lives in marker classes a separate stylesheet defines, and a published registry that writes the same design out in utilities. This library has no such stylesheet, so the utilities are the port — exactly as the Field took its separator's box from there. The one thing kept from the Base UI style is the spelling of the orientation variants: `data-vertical:h-auto` rather than `data-[orientation=vertical]:h-auto`, because `globals.css` defines that `@custom-variant` and every component here is written in the short form.
// shadcn // rahti-ui
<ButtonGroup> <ButtonGroup>
<ButtonGroupSeparator /> <ButtonGroupSeparator />
<ButtonGroupText asChild> <ButtonGroupText element="label">
// the builder, for a child that needs the runtime
let group = button_group().vertical();
group.render(Html::concat([
button().outline().on("click", "up()").render(…),
group.separator().render(Html::empty()),
button().outline().on("click", "down()").render(…),
]))The boundary
`>` is a direct-child combinator and `:first-child` / `:last-child` count every element in the box, so a Button Group is the one component here that cares what shape its children render as. Every tag in this library is one literal element while it is plain, and a fragment the moment it needs the runtime — a `{…}` binding or a `class` override. A `render` carries no script and is always one element, which is why it is the answer. The group's own script has the same problem from the inside, and goes *beside* the group rather than in it.
Welded — three plain tags
Broken — the middle tag carries a binding, so it is a fragment
Correct on the wire and wrong after hydration. The runtime replaces the fragment's comment range with a `<pp-fragment>`, and `display: contents` hides that from layout but not from a combinator — so `[&>*]` now matches the wrapper and never reaches "Two", which keeps all four of its corners and the left border its neighbour was supposed to lend it. Read the group's children back and you get `button, pp-fragment, button`, which is the whole diagnosis.
Fixed — the same binding, through the builder
Controlled, page scope
Not one tag in here. `<ButtonGroup>` 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 `view`. So a group a page drives is built with `button_group()` and rendered into the page's own block, and the same goes for every child of it. Every `render` in this library carries no `<script>`, which is what keeps the whole strip inside one scope.
Showing: {view}
The readout in the middle is a ButtonGroupText, and its `{count}` is written with `Html::from_raw` — an interpolation is text the runtime reads back out of the DOM, and a nested `html!` would be one more scope between it and the state.