Resizable split panes in React, keyboard accessible

Build resizable split panes in React with Tailwind: drag and keyboard operable handles, min and max sizes, collapsible sidebars, nesting and persisted sizes.

By , MiniDev12 min read

Resizable split panes in React come down to a flex container, a list of sizes in percent, and a handle between each pair of panels that moves one boundary with the pointer or the arrow keys. The handle is a role="separator" with aria-valuenow, which is what makes the layout keyboard accessible, and that part is easy to forget when the drag works.

This guide uses the free MiniDev UI ResizablePanels, which does all of the above in one file with no dependency, and builds an editor layout around it with FileTree and CodeBlock: a collapsible explorer, a code pane and a terminal in a nested vertical group, sizes persisted between visits. It also covers where the simpler SplitPane is the better choice.

Split pane or resizable panels

SplitPane is a static two column grid: left and right slots, a hairline between them, and it stacks on small screens. There is no handle, which is right for side by side content that the user never needs to rebalance, like a code sample next to its preview in docs. ResizablePanels is for workspaces where the balance is a preference: editors, inspectors, mail clients, anything with a sidebar someone wants narrower.

Install

bash
npx shadcn@latest add @minidev/resizable-panels
npx shadcn@latest add @minidev/file-tree
npx shadcn@latest add @minidev/code-block

ResizablePanels has no dependencies beyond lucide-react for its demo icons. FileTree animates folders with motion/react. Import the token stylesheet once (styles.css) so bg-sunken, border-border and the accent handle colour resolve.

Two panes in one line

For the common case there is a shorthand: pass left, right and an initial percentage, and the component renders two padded, scrollable panels with a handle between them, the first clamped between 20 and 80 percent.

tsx
<ResizablePanels left={<Inbox />} right={<Message />} initial={35} className="h-[36rem] rounded-xl border border-border" />

The group is h-full w-full, so give it a height, or put it in a parent that has one. A split layout with height: auto collapses to the taller pane and the handle has nothing to resize vertically.

Panels and handles: the full API

For anything beyond two panes, compose ResizablePanel and ResizableHandle as direct children of ResizablePanels. The group reads each panel's props to build its constraints, so the children must be those two components, not fragments or wrappers around them.

PropOnWhat it does
orientationResizablePanels"horizontal" (columns, default) or "vertical" (rows)
defaultSizes / sizes / onSizesChangeResizablePanelsUncontrolled start sizes, controlled sizes, and the callback fired on every drag, key press or snap, all in percent
keyboardStepResizablePanelsPercent per arrow key, default 2; Shift multiplies by five
defaultSizeResizablePanelStart size in percent; panels without one share what is left
minSize / maxSizeResizablePanelClamp in percent, defaults 10 and 100
collapsible / collapsedSize / onCollapseResizablePanelSnap to collapsedSize when dragged well below minSize; the callback fires on each change
aria-labelResizableHandleName of the separator, default "Resize columns" or "Resize rows"
withGripResizableHandleShow the grip pill at rest instead of only on hover and focus

Sizes always sum to 100 within a group. Explicit defaultSize values are scaled only if they overflow, and the remainder is split evenly among panels without one, so defaultSize={24} on the sidebar with nothing on the main pane gives 24 and 76.

An editor layout

Three panes: the explorer, a vertical group with the code and a terminal, and nothing else. FileTree reports the selected path through onSelect, which also fires for folders, so the handler checks the path is a file it knows before switching the editor.

app/editor/editor.tsxtsx
"use client"
import * as React from "react"
import { CodeBlock } from "@/components/ui/code-block"
import { FileTree, type FileTreeNode } from "@/components/ui/file-tree"
import { ResizableHandle, ResizablePanel, ResizablePanels } from "@/components/ui/resizable-panels"

const FILES: FileTreeNode[] = [
  { name: "app", children: [{ name: "page.tsx" }, { name: "layout.tsx" }, { name: "globals.css", status: "M" }] },
  { name: "components", children: [{ name: "invoice-card.tsx", status: "A" }] },
  { name: "package.json" },
]

const SOURCE: Record<string, string> = {
  "app/page.tsx": ["import { InvoiceCard } from \"@/components/invoice-card\"", "", "export default function Page() {", "  return <InvoiceCard id=\"LUM-0147\" />", "}"].join("\n"),
  "app/layout.tsx": ["export default function Layout({ children }) {", "  return <body>{children}</body>", "}"].join("\n"),
  "components/invoice-card.tsx": ["export function InvoiceCard({ id }: { id: string }) {", "  return <article>{id}</article>", "}"].join("\n"),
}

export function Editor() {
  const [file, setFile] = React.useState("app/page.tsx")
  const [explorerHidden, setExplorerHidden] = React.useState(false)

  return (
    <ResizablePanels className="h-[32rem] rounded-xl border border-border bg-surface shadow-raised">
      <ResizablePanel defaultSize={24} minSize={16} maxSize={40} collapsible collapsedSize={0} onCollapse={setExplorerHidden}>
        <FileTree
          data={FILES}
          title="lumen-web"
          defaultExpanded={["app", "components"]}
          selected={file}
          onSelect={(path) => { if (path in SOURCE) setFile(path) }}
          className="h-full max-w-none rounded-none border-0 shadow-none"
        />
      </ResizablePanel>
      <ResizableHandle aria-label="Resize file explorer" withGrip={explorerHidden} />
      <ResizablePanel minSize={30}>
        <ResizablePanels orientation="vertical">
          <ResizablePanel defaultSize={70} minSize={30} className="overflow-auto p-3">
            <CodeBlock code={SOURCE[file]} filename={file} language="tsx" showLineNumbers className="shadow-none" />
          </ResizablePanel>
          <ResizableHandle aria-label="Resize terminal" />
          <ResizablePanel defaultSize={30} minSize={15} collapsible collapsedSize={0} className="overflow-auto bg-sunken p-3 font-mono text-xs text-fg-muted">
            <p><span className="text-accent-fg">$</span> pnpm dev</p>
            <p className="text-success">ready on http://localhost:3000</p>
          </ResizablePanel>
        </ResizablePanels>
      </ResizablePanel>
    </ResizablePanels>
  )
}
  • A ResizablePanel is overflow-hidden with min-w-0, so its content never widens the group. Scrolling is opt in: put overflow-auto on the panel or on a child, as the code and terminal panes do.
  • The nested group fills its panel because ResizablePanels is h-full w-full. Nesting works in either direction and to any depth; each group keeps its own sizes.
  • FileTree ships as a card (max-w-72, rounded, bordered). Inside a pane, flatten it with className as above and give it h-full.
  • withGrip={explorerHidden} shows the handle's grip pill permanently once the explorer is collapsed, so there is a visible affordance to bring it back. Enter on the focused handle also reopens it.
Resizable PanelsuiReact split panes with draggable handles in both directions, min and max sizes, snap to collapse, keyboard resizing, double click reset and nested groups.npx shadcn@latest add @minidev/resizable-panels

Collapsing with a snap

A collapsible panel behaves normally until it is dragged below the midpoint between collapsedSize and minSize, then it snaps to collapsedSize. Dragging back past that point expands it to minSize. With collapsedSize={0} the panel disappears entirely but keeps its place in the group; a collapsedSize of 4 leaves a sliver with, for example, an icon rail.

onCollapse fires with true or false only when the state changes, which is the right hook for persisting a "sidebar hidden" preference or swapping the explorer for a toolbar button. The separator's aria-valuemin reports collapsedSize for collapsible panels, so screen readers hear the real range.

Persist the sizes

Nothing is persisted by the component. Control sizes and write them wherever you keep preferences. Reading localStorage must wait for mount, so the first render uses the defaults and the saved sizes apply right after; sizes that do not match the panel count are ignored, which protects you when a panel is added later.

tsx
const KEY = "editor:sizes"
const [sizes, setSizes] = React.useState<number[] | undefined>()

React.useEffect(() => {
  try {
    const saved = localStorage.getItem(KEY)
    if (saved) setSizes(JSON.parse(saved))
  } catch {}
}, [])

<ResizablePanels
  sizes={sizes}
  onSizesChange={(next) => {
    setSizes(next)
    try { localStorage.setItem(KEY, JSON.stringify(next)) } catch {}
  }}
>

While sizes is undefined the group is uncontrolled and uses defaultSizes or each panel's defaultSize; once set, it is controlled. In a Next.js app you can avoid the first paint jump by reading the sizes from a cookie on the server and passing them as defaultSizes, which is the same approach the sidebar layout guide uses for the collapsed state.

Keyboard support and the separator role

Each handle is focusable and announces itself as a separator with its current size, minimum and maximum. The keys map to the group's orientation:

KeyHorizontal groupVertical group
Left and Right arrowsMove the boundary by keyboardStep percentIgnored
Up and Down arrowsIgnoredMove the boundary by keyboardStep percent
Shift plus arrowFive stepsFive steps
Home and EndSlam to the minimum or maximumSame
EnterCollapse or expand the panel before the handle, if it is collapsibleSame
Double clickReset the pair to its default splitSame
  • Give every handle an aria-label that says what it resizes ("Resize file explorer"), not just "Resize columns". A layout with three separators is otherwise three identical names.
  • The handle sets aria-controls to the panel before it, and aria-valuenow updates live, so a screen reader user hears "24 percent" change as they press the keys.
  • The hairline swells and turns accent on focus through focus-visible styles, so mouse users see no ring and keyboard users see where they are.
  • The hit area is 8 pixels wide with touch-none, which is enough for a mouse and tolerable on a tablet. On touch screens, consider withGrip so the handle is discoverable.

Stacking on small screens

The zero prop demo switches to rows below 560 pixels, but your own layouts have to decide. Flip orientation on a media query with useSyncExternalStore, which renders the server default and swaps after hydration without a mismatch:

tsx
function useNarrow(px: number) {
  const q = "(max-width: " + (px - 1) + "px)"
  return React.useSyncExternalStore(
    (cb) => { const m = matchMedia(q); m.addEventListener("change", cb); return () => m.removeEventListener("change", cb) },
    () => matchMedia(q).matches,
    () => false
  )
}

const narrow = useNarrow(640)
<ResizablePanels orientation={narrow ? "vertical" : "horizontal"}>

Sizes carry over when the orientation changes, so a 24 percent sidebar becomes a 24 percent top row. If that is too short, pass different defaultSizes per orientation and key the group on it so it remounts.

Pitfalls

  • Percent limits on narrow groups. minSize={16} is 64 pixels in a 400 pixel group. For a pixel floor, add a min-w-48 class to the panel: flex honours it, at the cost of the reported percentages drifting from what is drawn while the floor is active.
  • Wrapping panels in your own components. The group inspects its direct children for ResizablePanel elements. A <Sidebar /> that renders a ResizablePanel inside is not seen; render the panel at the top level and put your component inside it.
  • Conditional panels. Adding or removing a panel changes the group's signature and recomputes the defaults, which discards the current sizes. Keep the panel and collapse it instead, or key the group so the reset is intentional.
  • Forgetting the height. h-full needs a parent with a height. Use a fixed h-[32rem], h-dvh minus the header, or a flex column parent with min-h-0 on the group.
  • Dragging text selection. The group adds select-none while a drag is active, so text in the panes does not highlight. If you add your own pointer handlers inside a pane, call e.stopPropagation() only when you must; the handle uses pointer capture and does not depend on bubbling.

Components used in this guide

All three are free and MIT licensed, with the rest of the editor and console pieces (terminal window, diff view, console output, JSON viewer) in the developer tools category. The explorer and code panes pair naturally with the command palette guide for file switching by keyboard. If you want a whole IDE style product built, the MiniDev studio builds on this kit.

File TreeuiA React file tree for code editor sidebars with icons by file type, git status letters, spring folder animation, a filter that expands matches and full keyboard support.npx shadcn@latest add @minidev/file-treeCode BlockuiReact code block with zero dependency syntax coloring, a language tag, optional filename and line numbers, and a copy button that confirms Copied.npx shadcn@latest add @minidev/code-blockSplit PaneuiStatic two-column split pane that stacks on mobile and sits side by side from md up, with a hairline divider. Handy for compare views and editor previews.npx shadcn@latest add @minidev/split-pane
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

Frequently asked questions

How do I make a resizable panel keyboard accessible?

Make the handle a focusable element with role="separator", aria-orientation, aria-valuenow, aria-valuemin and aria-valuemax, and move the boundary on the arrow keys with Home and End for the extremes. The kit's ResizableHandle does this and adds Enter to collapse and a double click to reset.

Should panel sizes be in pixels or percent?

Percent keeps the layout proportional when the window resizes and makes persisted sizes portable between screens. Use a CSS min-width on a panel when a pane needs a pixel floor, such as a tree that becomes useless under 200 pixels.

How do I save the panel sizes between visits?

Control the group with sizes and onSizesChange, and write the array to localStorage or the user's settings on each change. Apply the saved sizes after mount, or read them on the server into defaultSizes to avoid a jump on first paint.

More guides