Skip to content
Layout Primitives

Resizable

Panels the user can drag the boundary between, on react-resizable-panels. Sizes default to percentages of the group, so a layout dragged at one window width still makes sense at another.

Sidebar
Editor

Import

import {
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
} from "@mikenotthepope/substrateui"

Composition

A handle goes between every pair of panels — not before the first or after the last.

ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanel

The height goes on the parent

The group sets height: 100%; width: 100% as an inline style, and an inline style beats a class. So a className="h-48" on ResizablePanelGroup does nothing: the group takes its size from whatever contains it, and in a parent with no resolved height the panels collapse to their content.

Wrap it in a sized element, as every example on this page does. That is also where the border and the rounding belong — put them on the group and the panels overflow them.

Nesting the other way

A panel can hold another group, which is how the three-pane editor layout is built. The inner group sets its own orientation, and the handle lays its grip along the rule to match.

Files
Editor
Terminal

Collapsing a panel

collapsible lets a panel snap shut once it is dragged below its minSize, rather than sticking at the minimum. It is the behaviour a sidebar wants — drag it small and it disappears, drag the handle back and it returns to its minimum.

Drag me shut
Content

Remembering the layout

onLayoutChanged fires once the pointer is released and hands you a map of panel id to size. Store that, and pass it back as defaultLayout. Give the panels stable ids — the map is keyed by them, and generated ids change on every mount. onLayoutChange fires on every pointer move instead, which is for a live readout, not for writing to storage.

Read the stored layout lazily, as above — reading localStorage during a server render throws.

const [layout, setLayout] = useState<Layout | undefined>(() =>
  JSON.parse(localStorage.getItem("editor-layout") ?? "null") ?? undefined
)

<ResizablePanelGroup
  orientation="horizontal"
  defaultLayout={layout}
  onLayoutChanged={(next) => {
    setLayout(next)
    localStorage.setItem("editor-layout", JSON.stringify(next))
  }}
>
  <ResizablePanel id="sidebar" defaultSize={30}>…</ResizablePanel>
  <ResizableHandle withHandle />
  <ResizablePanel id="editor" defaultSize={70}>…</ResizablePanel>
</ResizablePanelGroup>

Direction

orientation means the axis, not the writing direction — "horizontal" lays the panels along the inline axis, and in RTL the first panel in your JSX is the rightmost. Source order is reading order, so nothing needs changing.

The grip is a GripVerticalglyph and is symmetric, so it has nothing to mirror. The handle's widened hit area is centred with a transform rather than pinned to a side, which is direction-neutral too.

Accessibility

Each handle is a focusable role="separator" carrying aria-valuenow, aria-valuemin, and aria-valuemax, so arrow keys resize once it has focus and Home and End drive it to the extremes. Double-click resets it. All of that comes from the library.

Use withHandle. The rule is 1px, and although a pseudo-element widens the drag target to 4px, a boundary a user cannot see is a boundary they will not think to drag. The visible grip is also the only thing saying the layout is adjustable.

Set minSize on panels holding real content, or use collapsible. A panel dragged to nothing hides its content visually without removing it from the accessibility tree, which leaves a screen-reader user reading a panel a sighted user cannot see.

API Reference

ResizablePanel is Panel from react-resizable-panels unchanged, and the other two add styling to its Group and Separator. See the react-resizable-panels docs for the full surface, including the useGroupRef and usePanelRef hooks.

PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Which axis the panels sit along. Sets the group's flex-direction inline.
defaultLayoutLayoutA map of panel id to size. Store what onLayoutChanged hands you and pass it back to restore a layout.
onLayoutChanged(layout: Layout) => voidFired once the drag ends. The one to persist from.
onLayoutChange(layout: Layout) => voidFired on every pointer move during a drag. For live readouts, not for saving.
disabledbooleanfalseFreeze the layout — every panel in the group stops resizing.
groupRefRef<GroupImperativeHandle>Exposes getLayout() and setLayout(), for resetting or applying a layout from outside.
idstring | numbera generated idNames the group. Also appears as data-group, and is what a stored layout is keyed against.

ResizablePanel

PropTypeDefaultDescription
defaultSizenumber | stringshared evenlyStarting size. A number is a percentage of the group; a string can carry any CSS unit.
minSizenumber | string0%Smallest size — the drag stops here.
maxSizenumber | string100%Largest size.
collapsiblebooleanfalseLet the panel snap shut to collapsedSize once dragged below minSize.
collapsedSizenumber | string0%The size a collapsible panel snaps to.
groupResizeBehavior"preserve-relative-size" | "preserve-pixel-size""preserve-relative-size"What happens to this panel when the window resizes. A group needs at least one panel on relative size.
idstring | numbera generated idNames the panel. Give it a stable one if you persist layouts, since the layout map is keyed by panel id.

ResizableHandle

PropTypeDefaultDescription
withHandlebooleanfalseDraw a visible grip in the middle of the rule. Worth it — a bare 1px line is hard to find.
disabledbooleanfalsePin this one boundary, leaving the rest of the group draggable.
disableDoubleClickbooleanfalseTurn off the double-click-to-reset shortcut.