Resizable
Draggable split panes built on react-resizable-panels v4. Horizontal or vertical, with keyboard-accessible handles and persistable layout.
Installation
pnpm dlx @hex-core/cli add resizableThree-pane layout
Sidebar + main + inspector. Each panel has minSize so the user can't collapse a pane to nothing.
<ResizablePanelGroup orientation="horizontal" className="min-h-[400px] rounded-lg border">
<ResizablePanel defaultSize={20} minSize={15}>
<div className="h-full p-4">
<p className="text-sm font-medium">Sidebar</p>
<p className="text-xs text-muted-foreground">Files, history, search</p>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
<div className="h-full p-6">
<p className="text-sm font-medium">Editor</p>
<p className="text-xs text-muted-foreground">Document body</p>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={18}>
<div className="h-full p-4">
<p className="text-sm font-medium">Inspector</p>
<p className="text-xs text-muted-foreground">Metadata, comments</p>
</div>
</ResizablePanel>
</ResizablePanelGroup>Persisted sizes
Persist the user's split sizes via the package's id-keyed Layout map. The onLayoutChanged callback receives a `Record<string, number>` keyed by ResizablePanel id; serialize via JSON.stringify and restore with a strict guard that the cached value is an object of finite numbers.
import { useEffect, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
type Layout = Record<string, number>;
const STORAGE_KEY = "docs:editor-layout";
const DEFAULT_LAYOUT: Layout = { files: 25, editor: 75 };
function isLayout(value: unknown): value is Layout {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.values(value).every((n) => typeof n === "number" && Number.isFinite(n))
);
}
export function PersistedSplit() {
const [layout, setLayout] = useState<Layout>(DEFAULT_LAYOUT);
useEffect(() => {
try {
const cached = window.localStorage.getItem(STORAGE_KEY);
if (cached === null) return;
const parsed: unknown = JSON.parse(cached);
if (isLayout(parsed)) setLayout(parsed);
} catch {
// Storage corrupted — fall back to defaults; don't crash the editor.
}
}, []);
return (
<ResizablePanelGroup
orientation="horizontal"
defaultLayout={layout}
onLayoutChanged={(next) => {
// No setLayout — `defaultLayout` is an initial-only prop, so the panel
// group owns the live size during drags. Persistence is one-way: write
// on change, read on the next mount.
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// Quota exceeded — drop the persistence; the user's session keeps working.
}
}}
className="min-h-[300px] rounded-lg border"
>
<ResizablePanel id="files" minSize={15}>
<div className="flex h-full items-center justify-center p-6 text-sm">Files</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="editor" minSize={30}>
<div className="flex h-full items-center justify-center p-6 text-sm">Editor</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | string | horizontal | Group orientation: 'horizontal' | 'vertical' |
defaultLayout | object | — | Initial panel sizes as a `Record<string, number>` keyed by ResizablePanel `id` (flexGrow values summing to 100). |
onLayoutChanged | function | — | Fires after a drag completes; receives the new `Record<string, number>` layout keyed by ResizablePanel `id`. Prefer this over `onLayoutChange` for persistence — it does not require debouncing. |
disabled | boolean | false | Disable resizing for the whole group |
AI Guidance
When to use
Use for editor-style layouts (file tree + editor), dashboards with configurable panels, or any UI where users need to trade space between regions. Layouts can be persisted to localStorage via the group's id.
When not to use
Don't use for responsive layouts — use CSS grid/flex with breakpoints. Don't use for modal layouts (use Dialog/Sheet). Don't nest deeply (>2 levels) — it hurts a11y and perception. Don't use if panels need to collapse/expand as a single action (use Collapsible).
Common mistakes
- Forgetting ResizableHandle between panels — they won't resize
- Using 'cols'/'rows' instead of orientation='horizontal'/'vertical' (old v1 API)
- Not providing defaultSize on each panel — initial layout will be uneven
- Rendering panel content that changes DOM size during drag — react-resizable-panels performance suffers
- Omitting a group id when you want layout to persist via localStorage
Accessibility
ResizableHandle is focusable and resizable via keyboard arrows. role='separator' is set, with aria-valuenow/min/max wired by react-resizable-panels. The grab-grip is aria-hidden (decorative).
Related components
Token budget: 700
Verified against @hex-core/components@1.12.0