Resizable

Draggable split panes built on react-resizable-panels v4. Horizontal or vertical, with keyboard-accessible handles and persistable layout.

One
Two
Three

Installation

pnpm
pnpm dlx @hex-core/cli add resizable

Three-pane layout

Sidebar + main + inspector. Each panel has minSize so the user can't collapse a pane to nothing.

tsx
<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.

tsx
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

PropTypeDefaultDescription
orientation
stringhorizontalGroup orientation: 'horizontal' | 'vertical'
defaultLayout
objectInitial panel sizes as a `Record<string, number>` keyed by ResizablePanel `id` (flexGrow values summing to 100).
onLayoutChanged
functionFires 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
booleanfalseDisable 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