Skip to content

Resizable

Stable

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

Sidebar
Header
Content

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%">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

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

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

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.

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"

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%"></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%
Do.Sensible minimums keep every panel usable, whatever the drag.
No limits
Don’t.Without 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. The divider turns brand-colored while focused.
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.