Skip to content

Resizable

Panels that people can resize by dragging or with the keyboard, for layouts whose proportions are personal.

Sidebar
Header
Content
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

function Label({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-full items-center justify-center p-6">
      <span className="text-sm font-medium text-muted-foreground">
        {children}
      </span>
    </div>
  )
}

export default function ResizableDemo() {
  return (
    <div className="h-48 w-full max-w-md">
      <ResizablePanelGroup
        orientation="horizontal"
        className="rounded-xl border bg-card shadow-raised"
      >
        <ResizablePanel defaultSize="50%" minSize="20%">
          <Label>Sidebar</Label>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel defaultSize="50%" minSize="20%">
          <ResizablePanelGroup orientation="vertical">
            <ResizablePanel defaultSize="30%" minSize="20%">
              <Label>Header</Label>
            </ResizablePanel>
            <ResizableHandle />
            <ResizablePanel defaultSize="70%" minSize="20%">
              <Label>Content</Label>
            </ResizablePanel>
          </ResizablePanelGroup>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}

Anatomy

Files
Editor
  1. 1GroupLays the panels out in a row or a column and shares the space between them.
  2. 2PanelOne region. Its size is a share of the group, with optional limits.
  3. 3HandleThe line between two panels. Drag it, or focus it and use the arrow keys.

Installation

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/resizable.json

Usage

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"
<div className="h-80">
  <ResizablePanelGroup orientation="horizontal">
    <ResizablePanel defaultSize="30%" minSize="20%">Sidebar</ResizablePanel>
    <ResizableHandle />
    <ResizablePanel defaultSize="70%" minSize="30%">Content</ResizablePanel>
  </ResizablePanelGroup>
</div>

A group always fills its parent — size the parent, not the group. Sizes accept strings with units ("30%", "240px", "16rem"); a bare number means pixels, and a unitless string means percent.

Coming from react-resizable-panels v2?
prfct uses v4. direction is now orientation, PanelResizeHandle is Separator (wrapped as ResizableHandle), and defaultSize={30} means 30 pixels — write defaultSize="30%".

Examples

Vertical

Stack panels with orientation="vertical" — a query above its results, an editor above a console.

Queryselect * from deployments where status = 'error'
3 rows · 12 ms
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export default function ResizableVertical() {
  return (
    <div className="h-64 w-full max-w-sm">
      <ResizablePanelGroup
        orientation="vertical"
        className="rounded-xl border bg-card shadow-raised"
      >
        <ResizablePanel defaultSize="35%" minSize="20%">
          <div className="flex h-full flex-col gap-1 p-4">
            <span className="text-sm font-semibold text-foreground">Query</span>
            <code className="font-mono text-xs text-muted-foreground">
              select * from deployments where status = &apos;error&apos;
            </code>
          </div>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel defaultSize="65%" minSize="30%">
          <div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
            3 rows · 12 ms
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}

With a grip

withHandle draws a small grip on the divider. Use it when the divider is easy to miss — thin panels, low-contrast surfaces, touch devices.

Before
After
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export default function ResizableWithHandle() {
  return (
    <div className="h-48 w-full max-w-md">
      <ResizablePanelGroup
        orientation="horizontal"
        className="rounded-xl border bg-card shadow-raised"
      >
        <ResizablePanel defaultSize="40%" minSize="25%">
          <div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
            Before
          </div>
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel defaultSize="60%" minSize="25%">
          <div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
            After
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}

Nested layout

Groups nest to build real application layouts. Here the explorer is collapsible with minSize and maxSize, so it snaps closed when dragged past its minimum; the terminal is collapsible too. Focus a divider and press Enter to collapse or restore the panel before it.

componentsbutton.tsxdialog.tsxtokens.cssutils.ts
button.tsx
1import { cva } from "class-variance-authority"
2
3export const buttonVariants = cva([
4 "inline-flex items-center",
5])
Terminal
$ pnpm dev
✓ Ready in 594ms
import {
  ChevronDownIcon,
  FileCodeIcon,
  FolderIcon,
  TerminalIcon,
} from "lucide-react"

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

const files = ["button.tsx", "dialog.tsx", "tokens.css", "utils.ts"]

const code = [
  ["import", " { cva } ", "from", ' "class-variance-authority"'],
  [""],
  ["export", " const ", "buttonVariants", " = cva(["],
  ["  ", '"inline-flex items-center"', ","],
  ["])"],
]

export default function ResizableIde() {
  return (
    <div className="h-80 w-full max-w-2xl">
      <ResizablePanelGroup
        orientation="horizontal"
        className="rounded-xl border bg-card shadow-raised"
      >
        <ResizablePanel
          defaultSize="26%"
          minSize="18%"
          maxSize="40%"
          collapsible
          collapsedSize="0%"
        >
          <div className="flex h-full flex-col gap-1 bg-surface p-2 text-body-xs">
            <span className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
              <ChevronDownIcon className="size-3.5" aria-hidden="true" />
              <FolderIcon className="size-3.5" aria-hidden="true" />
              components
            </span>
            {files.map((file, index) => (
              <span
                key={file}
                className={
                  index === 0
                    ? "flex items-center gap-2 rounded-md bg-accent px-2 py-1 pl-7 text-foreground"
                    : "flex items-center gap-2 rounded-md px-2 py-1 pl-7 text-muted-foreground"
                }
              >
                <FileCodeIcon
                  className="size-3.5 shrink-0"
                  aria-hidden="true"
                />
                <span className="truncate">{file}</span>
              </span>
            ))}
          </div>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel defaultSize="74%">
          <ResizablePanelGroup orientation="vertical">
            <ResizablePanel defaultSize="68%" minSize="30%">
              <div className="flex h-full flex-col">
                <div className="flex h-9 items-center border-b px-3 font-mono text-xs text-foreground">
                  button.tsx
                </div>
                {/* A narrow pane scrolls its code, as an editor would. */}
                <pre
                  tabIndex={0}
                  aria-label="button.tsx"
                  className="flex-1 scrollbar-thin overflow-auto p-3 font-mono text-xs leading-5 focus-ring-inset"
                >
                  {code.map((line, i) => (
                    <div key={i} className="flex gap-4">
                      <span className="w-4 text-right text-muted-foreground select-none">
                        {i + 1}
                      </span>
                      <span>
                        {line.map((token, j) => (
                          <span
                            key={j}
                            className={
                              token.trim().startsWith('"')
                                ? "text-green-11"
                                : [
                                      "import",
                                      "from",
                                      "export",
                                      "const",
                                    ].includes(token)
                                  ? "text-violet-11"
                                  : token === "buttonVariants"
                                    ? "text-brand-11"
                                    : "text-foreground"
                            }
                          >
                            {token}
                          </span>
                        ))}
                      </span>
                    </div>
                  ))}
                </pre>
              </div>
            </ResizablePanel>
            <ResizableHandle />
            <ResizablePanel defaultSize="32%" minSize="15%" collapsible>
              <div className="flex h-full flex-col bg-surface">
                <div className="flex h-8 items-center gap-1.5 border-b px-3 text-xs font-medium text-muted-foreground">
                  <TerminalIcon className="size-3.5" aria-hidden="true" />
                  Terminal
                </div>
                <pre className="p-3 font-mono text-xs leading-5 text-muted-foreground">
                  $ pnpm dev{"\n"}
                  <span className="text-green-11">✓</span> Ready in 594ms
                </pre>
              </div>
            </ResizablePanel>
          </ResizablePanelGroup>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}

Remembering the layout

useDefaultLayout restores a saved layout on mount and saves it after each resize. Give every panel an id so saved sizes map back to the right panel. localStorage exists only in the browser, so this preview renders the default layout on the server and switches to the saved one after hydration.

List
DetailResize, then reload the page.
"use client"

import * as React from "react"
import { useDefaultLayout, type GroupProps } from "react-resizable-panels"

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

function Layout(props: Pick<GroupProps, "defaultLayout" | "onLayoutChanged">) {
  return (
    <ResizablePanelGroup
      orientation="horizontal"
      className="rounded-xl border bg-card shadow-raised"
      {...props}
    >
      <ResizablePanel id="list" defaultSize="40%" minSize="25%">
        <div className="flex h-full items-center justify-center p-6 text-sm font-medium text-muted-foreground">
          List
        </div>
      </ResizablePanel>
      <ResizableHandle withHandle />
      <ResizablePanel id="detail" defaultSize="60%" minSize="30%">
        <div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
          <span className="text-sm font-medium text-muted-foreground">
            Detail
          </span>
          <span className="text-xs text-muted-foreground">
            Resize, then reload the page.
          </span>
        </div>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

function PersistedLayout() {
  // Restores the last layout from localStorage and saves it after each resize.
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
    id: "prfct-docs-persisted-layout",
    storage: localStorage,
  })
  return (
    <Layout defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged} />
  )
}

const subscribe = () => () => {}

export default function ResizablePersisted() {
  // localStorage only exists in the browser: render the default layout on the
  // server and during hydration, then switch to the saved one.
  const hydrated = React.useSyncExternalStore(
    subscribe,
    () => true,
    () => false
  )

  return (
    <div className="h-40 w-full max-w-md">
      {hydrated ? <PersistedLayout /> : <Layout />}
    </div>
  )
}

In a server-rendered Next.js app, store the layout in a cookie instead: the server can read it, so the first paint already has the saved sizes and nothing shifts.

app/inbox/layout.tsx
import { cookies } from "next/headers"

import { InboxPanels } from "./inbox-panels"

export default async function InboxLayout() {
  const saved = (await cookies()).get("layout:inbox")?.value
  return <InboxPanels defaultLayout={saved ? JSON.parse(saved) : undefined} />
}
app/inbox/inbox-panels.tsx
"use client"

import type { Layout } from "react-resizable-panels"

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function InboxPanels({ defaultLayout }: { defaultLayout?: Layout }) {
  return (
    <ResizablePanelGroup
      defaultLayout={defaultLayout}
      onLayoutChanged={(layout) => {
        document.cookie = `layout:inbox=${JSON.stringify(layout)}; path=/; max-age=31536000`
      }}
    >
      <ResizablePanel id="list" defaultSize="40%" minSize="25%">…</ResizablePanel>
      <ResizableHandle />
      <ResizablePanel id="detail" defaultSize="60%" minSize="30%">…</ResizablePanel>
    </ResizablePanelGroup>
  )
}

Guidelines

When to use

  • In tools where people spend long sessions and have strong preferences about space: editors, mail, file managers, dashboards with side panels.
  • When two views compete for space and the right split depends on the task.

When not to use

  • On marketing and content pages, where the layout should be composed for the reader.
  • On small screens. Below the md breakpoint, switch to a single column, tabs or a Sheet instead of divisible panels.
  • To show or hide a panel entirely — a toggle button is clearer than dragging to zero.

Set limits

Always give panels a minSize so content can't be crushed into uselessness, and a maxSize when one side must stay visible. Make a panel collapsible when hiding it completely is a legitimate choice.

min 25%
min 40%
DoSensible minimums keep every panel usable, whatever the drag.
No limits
Don’tWithout limits, a panel can be dragged down to a useless sliver.

Persist what people choose

A layout someone adjusted is a preference. Save it with useDefaultLayout (or your own storage via onLayoutChanged) so it survives reloads.

Accessibility

Each divider is a focusable role="separator" with aria-valuenow, aria-valuemin and aria-valuemax describing the size of the panel before it, and aria-orientation perpendicular to the group (a horizontal group has vertical dividers).

KeyBehavior
Tab
Moves focus to the next divider. While focused, the divider turns brand-colored and shows a focus ring.
←→
Resizes a horizontal group by 5%.
↑↓
Resizes a vertical group by 5%.
Home
Shrinks the panel before the divider to its minimum.
End
Grows the panel before the divider to its maximum.
Enter
Collapses the panel before the divider, or restores it — when that panel is collapsible.
F6ShiftF6
Moves focus to the next or previous divider in the same group.
  • Double-click a divider to reset its panels to their default sizes (disable with disableDoubleClick).
  • Hit area. Dividers are 1px wide but their target is larger — the library enforces a minimum hit size, bigger for coarse pointers.
  • Label the panels. Give panels headings or aria-labels so a screen reader user knows what they're resizing.

API reference

ResizablePanelGroup

The container that lays out panels and dividers. Renders a <div> that fills its parent. Wraps Group from react-resizable-panels v4.

PropTypeDefault
orientation

Direction panels are laid out and resized in.

"horizontal" | "vertical""horizontal"
defaultLayout

Panel sizes (by panel id, in percent) to restore on mount.

Record<string, number>No default
onLayoutChanged

Called after a resize completes. meta.isUserInteraction is true for pointer and keyboard resizes.

(layout, meta) => voidNo default
onLayoutChange

Called continuously while resizing. Prefer onLayoutChanged for saving.

(layout) => voidNo default
disabled

Disables resizing for the whole group.

booleanfalse
resizePreviewMode

Resize panels live, or preview the divider and apply on release.

"panel" | "separator""panel"
groupRef

Imperative API: getLayout() and setLayout().

Ref<GroupImperativeHandle>No default

ResizablePanel

A resizable region. className applies to an inner <div>, so layout utilities won't fight the panel's flex sizing. Wraps Panel.

PropTypeDefault
id

Stable identifier. Required to persist or restore layouts.

string | numberNo default
defaultSize

Initial size. Numbers are pixels; strings may use %, px, rem, em, vh or vw.

number | stringNo default
minSize

Smallest size the panel can be resized to.

number | stringNo default
maxSize

Largest size the panel can be resized to.

number | stringNo default
collapsible

Lets the panel collapse to collapsedSize when dragged below minSize.

booleanfalse
collapsedSize

Size of the panel while collapsed.

number | string"0%"
onResize

Called when this panel's size changes.

(size, id, previousSize) => voidNo default
panelRef

Imperative API: collapse(), expand(), isCollapsed(), getSize() and resize().

Ref<PanelImperativeHandle>No default

ResizableHandle

The divider between two panels. Wraps Separator.

PropTypeDefault
withHandle

Shows a grip on the divider.

booleanfalse
disabled

Prevents this divider from resizing its neighbors.

booleanfalse
disableDoubleClick

Stops double-click from resetting panels to their default sizes.

booleanfalse

The divider exposes its state as data-separator: inactive, hover, active (dragging), focus or disabled — prfct colors it with the brand scale on hover, drag and focus.