Resizable
Draggable split panels on react-resizable-panels v4 — a token-driven rail that turns amber while active, with an optional grip chip and host-persisted layout.
Import
Resizable is a registry composite — you own the source after installing:
npx ploxum add resizableimport { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/resizable";Depends on react-resizable-panels (v4 — Group / Panel / Separator), @roger-emerson/ploxum-icons (GripVertical), and @roger-emerson/ploxum-utils (cn).
Usage
A horizontal group of three panels, split by grip handles:
<div className="h-[320px] w-full overflow-hidden rounded-sm border border-[var(--ploxum-color-border-subtle)]">
<ResizablePanelGroup id="workspace" orientation="horizontal">
<ResizablePanel defaultSize="25%" minSize="15%">
<Sidebar />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%">
<Canvas />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="25%" minSize="20%">
<Inspector />
</ResizablePanel>
</ResizablePanelGroup>
</div>Nest a vertical group inside a panel — handles take their divider orientation (and grip rotation) from their own group:
<ResizablePanelGroup id="shell" orientation="horizontal">
<ResizablePanel defaultSize="50%">
<MapCanvas />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%">
<ResizablePanelGroup id="rail" orientation="vertical">
<ResizablePanel defaultSize="35%" minSize="20%" className="min-h-0">
<AlertCenter />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="65%" minSize="20%" className="min-h-0">
<EventStream />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>Persist layout on the host by reading and restoring the v4 keyed layout map. onLayoutChange fires continuously during a drag, onLayoutChanged fires once on release — both hand you a Layout map ({ [panelId]: size }). Persist on onLayoutChanged so you write storage on settle, not every frame; restore via defaultLayout:
const [layout, setLayout] = useState(() => {
const saved = localStorage.getItem("ploxum.shell.layout");
return saved ? JSON.parse(saved) : undefined;
});
<ResizablePanelGroup
id="shell"
orientation="horizontal"
defaultLayout={layout}
onLayoutChanged={(next) => {
setLayout(next);
localStorage.setItem("ploxum.shell.layout", JSON.stringify(next));
}}
>
{/* panels — each must carry a stable `id` to key into the layout map */}
</ResizablePanelGroup>Props
ResizablePanelGroup
Wraps react-resizable-panels v4 Group (GroupProps). The wrapper sets data-slot and merges flex/orientation classes; everything else forwards through.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Split axis. v4 name — not the v2 direction. Drives the aria-orientation the rail and flex-col class key off. |
id | string | — | Stable group id. Keys panels into the layout map; required for persistence. |
defaultLayout | Layout (Record<string, number>) | — | Initial keyed layout — { [panelId]: size }. Restores a saved arrangement. Per-panel defaultSize alone does not initialize flex-grow; pass defaultLayout to size a group on mount. |
onLayoutChange | (layout: Layout) => void | — | Fires continuously during a drag with the keyed size map. |
onLayoutChanged | (layout: Layout) => void | — | Fires once on release. Use this for persistence (write storage on settle, not every frame). |
className | string | — | Merged after the wrapper's flex/orientation classes. |
Remaining GroupProps (e.g. keyboardResizeBy, storage, style) pass straight through to v4 Group.
ResizablePanel
Wraps react-resizable-panels v4 Panel (PanelProps). The wrapper only sets data-slot and forwards className.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Stable panel id — the key used in the group's layout map. |
defaultSize | `${number}%` | — | Initial size as a percentage string, e.g. "25%". |
minSize | `${number}%` | — | Lower bound; prevents the panel collapsing past this. |
maxSize | `${number}%` | — | Upper bound. |
collapsible | boolean | false | Allow collapse to collapsedSize when dragged below minSize. |
className | string | — | Applied to the panel element. Use min-h-0 so inner scrollers keep bounded height. |
Remaining PanelProps (order, collapsedSize, onResize, onCollapse, onExpand) pass straight through to v4 Panel.
ResizableHandle
Wraps react-resizable-panels v4 Separator (SeparatorProps) and adds one prop.
| Prop | Type | Default | Description |
|---|---|---|---|
withHandle | boolean | false | Renders the centered GripVertical chip. The grip rotates 90° automatically under a horizontal divider (vertical group). |
className | string | — | Merged after the rail's base + state classes. |
Remaining SeparatorProps (disabled, hitAreaMargins, onClick, tabIndex) pass straight through to v4 Separator.
Design rules
- Token-driven rail. The hairline reads
--ploxum-color-border-subtleat rest and switches to--ploxum-color-amber-defaultondata-[separator=hover]anddata-[separator=active]; keyboard focus paints--ploxum-color-border-focus. State drives the amber — never hue alone. - Grip is the only motion here. The rail's
transition-colorsand the grip chip are a spatial affordance, the sanctioned place for a flourish. It respectsmotion-reduce. Don't add motion to panel content. - Min sizes are mandatory on collapsible content. Set
minSizeon any panel hosting a scroller so it can't be dragged to zero and orphan its contents. - Bound inner scrollers. Panel content that scrolls (AlertCenter, EventStream) must sit in a
min-h-0flex context — without it the flex child grows to its content and the inner scroller loses its bounded height. - Layout is the host's to persist. The composite is uncontrolled by default; wire
id+onLayoutChanged→ storage →defaultLayoutat the host when an arrangement should survive reloads.
