Resizable
Panes divided by a rule the user can drag, and a keyboard that moves it too.
src/components/rahti_ui/resizable.rs
Basic
A horizontal group, which is what an unadjusted one is.
shadcn's own demo, part for part. The group is `h-[200px] max-w-md rounded-lg border` because the root is `flex h-full w-full` and nothing else — it has no size of its own, so in a parent with no height there is nothing for the panels to fill.
The rule between them is one pixel. The hit area over it is four, drawn by an `after:` pseudo-element that is invisible and centred on the rule — nobody can grab a pixel.
Turn JavaScript off and this still renders exactly as it does now: 50/50, bordered, with a focusable rule between the panes. It simply does not move. The sizes are `flex: 50 1 0px` written by the server, which is the whole reason that floor exists.
Vertical
The same group with its axis turned. The handles turn with it, and nothing at the call site says so.
`orientation="vertical"` puts `aria-orientation="vertical"` on the group, which is what `aria-[orientation=vertical]:flex-col` reads to turn the axis — and what every handle inside reads, through a child combinator, to know it is a flat rule rather than an upright one.
That inversion is the one structural change in this port. shadcn keys the handle on its own `aria-orientation`, which `react-resizable-panels` writes from a React context. There is no context here — children arrive already rendered — so the handle asks its parent instead. The module docs work through the three ways out and why this is the only one that draws the right thing before the runtime arrives.
With a grip
A rule nobody can see is a rule nobody drags.
`with_handle` is shadcn's `withHandle`, off by default in both. The grip is a twelve-by-sixteen box with `lucide`'s vertical grip inside it, and it carries `shrink-0` — taken from shadcn's Base UI style, because a twelve-pixel box inside a one-pixel-wide flex item disappears entirely without it.
The dots turn with the group, and the utility that turns them reaches through the same child combinator: `[[aria-orientation=vertical]>&>div]:rotate-90`. It is the grip `<div>` that rotates, not the `<svg>` — which is why the grip has to be the handle's only element child.
Nested
A group inside a panel of another group, running the other way.
shadcn's nested demo. The outer group runs across, the inner one runs down, and each group's handles are drawn from that group and no other — the combinator is `>` rather than a descendant space precisely so the inner group's `aria-orientation` cannot reach the outer group's rule.
The two groups are two independent scripts, and neither knows about the other. The inner group finds its own panels by walking its own `children` rather than by `querySelectorAll`, which is what keeps the outer group's panels out of it.
Limits
How small, how large, and what happens when a neighbour runs out.
`min_size` and `max_size` are percentages of the group, written as strings because that is what reads well in a tag — the same choice the Slider makes for `min`, `max` and `value`. They are rendered as `data-panel-min-size` and `data-panel-max-size` and read back by the group's script; nothing about them is enforced in Rust, because a panel cannot see its siblings.
Drag the second group's left rule all the way in. The middle panel stops at its own minimum and the *right* one starts giving up space instead — the script takes from the losing side panel by panel until it runs out, and hands the same total to the winning side the same way. A two-panel implementation would simply stick.
Collapsible
A panel that shrinks past its minimum to a rail, and says so.
Drag the rule left past 15%. The sidebar does not stop at its minimum — it snaps down to `collapsed_size="4"` and picks up `data-panel-collapsed`, which is what the `data-[panel-collapsed]:bg-muted` above is selecting on. Drag back out and it snaps to its minimum rather than crawling out of the rail.
This is the one place the port is deliberately not a copy. `react-resizable-panels` snaps the panel after the fact, which leaves the group's total adrift by the size of the jump. Here the snap is applied to the *gesture* — the drag is nudged so the panel lands on whichever end is nearer, and the corrected delta goes back through the same distribution — so the sizes add to exactly 100 on every frame. What you see is the neighbour moving with the snap instead of standing still.
A collapsible panel is worth an `overflow-hidden` thought: it is four percent wide when collapsed, and its content is cut rather than reflowed. That is the `PANEL` layer doing its second job — a flex item's `min-width: auto` would otherwise stop the drag dead at the width of the widest word inside.
Keyboard
Every handle is a tab stop, and every gesture has a key.
The WAI-ARIA window-splitter pattern, which is what a focusable `role="separator"` is. The arrows follow the group's axis — up and down in a vertical group — and the script keeps `aria-valuenow`, `aria-valuemin` and `aria-valuemax` on every handle in step with the panel before it.
The `label` prop is this port's, not shadcn's. A focusable splitter with no accessible name is announced as nothing at all, so an unlabelled handle is called `Resize` — the same gap the Scroll Area closes on its viewport. Set it to something that names the panes, and set it in the page's own language.
Remembering
A group that comes back where it was left.
`autosave="demo-editor"` is the primitive's `autoSaveId`. The layout is written to `localStorage` under `rahti-ui:resizable:demo-editor` at the end of every gesture and read back at mount. Drag the rule, reload the page, and the panes come back where they were left.
A saved layout is ignored unless it holds one finite number per panel, so adding or removing a panel starts the group over rather than restoring a layout that no longer describes it. Storage that throws — private mode, a blocked origin — is caught, and the group simply does not remember. The name has to be unique across the application rather than the page: two groups sharing one share a layout.
Disabled
A rule that is drawn and does not move.
`disabled` takes the handle out of the tab order, marks it `aria-disabled`, and swaps the gesture layer for one that only sets a cursor — so the pointer stops promising something the handle will not do. The script checks the same attribute and skips it.
The two layers are never joined. A live handle gets `touch-none select-none cursor-col-resize`; a disabled one gets `cursor-default` instead. The choice is made in Rust, where it is a `bool`, rather than as a `data-[disabled]:` variant laid over the top — which is why the class list never carries two answers to the cursor question and `audit::no_utility_contradicts_another` can hold the join exact.
Built in Rust
Panels a page assembles rather than writes.
Three panels and two handles, built with `resizable_panel().render(…)` and `Html::concat` rather than written as tags. The group's script finds its parts by their `data-slot`, not by how they were made, so this behaves exactly like the hand-written groups above.
The group itself has no `render`, and that is not an oversight. A group *is* its script, a script written in a method's `html!` is a block of its own, and the `pp-ref` that script needs to find the group would never resolve there. There is one way to make a group, and it is the tag.
The port
Where the strings came from, and what had to change.
What is not ported: `onLayout` and `onResize` callbacks, because there is no native resize event to bind — the layout is published as `data-panel-size` on every panel instead. `order`, because React needs it to re-attach a panel that unmounted and came back, and server-rendered children have one order. And imperative handles, because there is no component instance to hold.
The boundary
The class list without the component, and what must not be bound.
`resizable_panel_group_variants(…)` on a page's own element. Write `aria-orientation` alongside it — it is what turns the axis, and what every handle inside would read. This one has no script and no handles, so it is a picture of a group rather than a group.
The rule for bindings is the library's usual one, with a sharp edge. `onclick={…}` on any of the three tags lands on the element untouched and resolves when it fires, late enough to reach this page's state. But the group's script owns every panel's `style` and `data-panel-size` outright and rewrites them sixty times a second during a drag — so binding either is the one thing that will fight it, and lose. Bind `class`, or select on `data-panel-collapsed`.