Resizable Panels in React: Split View, Drag to Resize
Build drag-to-resize split panels in React from scratch or with react-resizable-panels. Covers split views, keyboard access, and production layout patterns.
Why Resizable Panels Are Harder Than They Look
You'd think splitting a div in two and letting the user drag the divider would be a 30-minute job. It isn't. Getting the resize math right, handling mouse and touch events without leaking listeners, persisting sizes across reloads, and making the whole thing keyboard-navigable — that's a solid afternoon of work, minimum. And it's easy to ship something that feels sluggish or drops pointer events the moment the cursor moves outside the panel boundary.
Resizable split views show up everywhere in developer tools: VS Code's sidebar, CodeSandbox's three-panel editor, the React DevTools inspector. They're also increasingly common in consumer products — email clients with a preview pane, note apps with a sidebar, dashboards where users want to allocate screen space the way they prefer.
Honestly, the hardest part isn't the math. It's the edge cases. What happens when the user drags the divider past a panel's minimum width? What if they're on a 320px mobile screen? What if they press Enter on the divider while focused via keyboard? A thoughtful implementation handles all of that. A naive one ships a broken split view that nobody touches after day one.
In 2026 you have two real options: build it yourself with pointer events and CSS flex/grid, or reach for react-resizable-panels — the library that Shadcn UI chose to power their ResizablePanelGroup primitive. We'll cover both.
The Pointer Events Approach: Building From Scratch
If you want zero dependencies and full control, the approach is clean: store the split ratio in state, attach onPointerDown to the divider handle, then listen for pointermove and pointerup on the document (not the element). Using the document as the target is the key insight — it means dragging quickly doesn't cause you to "lose" the handle.
import { useRef, useState, useCallback } from 'react';
export function SplitPane({
children,
direction = 'horizontal',
defaultSplit = 50,
minSize = 10,
}: {
children: [React.ReactNode, React.ReactNode];
direction?: 'horizontal' | 'vertical';
defaultSplit?: number;
minSize?: number;
}) {
const [split, setSplit] = useState(defaultSplit);
const containerRef = useRef<HTMLDivElement>(null);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const onMove = (ev: PointerEvent) => {
const rect = container.getBoundingClientRect();
let next =
direction === 'horizontal'
? ((ev.clientX - rect.left) / rect.width) * 100
: ((ev.clientY - rect.top) / rect.height) * 100;
next = Math.max(minSize, Math.min(100 - minSize, next));
setSplit(next);
};
const onUp = () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
},
[direction, minSize]
);
const isH = direction === 'horizontal';
return (
<div
ref={containerRef}
style={{ display: 'flex', flexDirection: isH ? 'row' : 'column', width: '100%', height: '100%' }}
>
<div style={{ [isH ? 'width' : 'height']: `${split}%`, overflow: 'auto' }}>
{children[0]}
</div>
<div
onPointerDown={handlePointerDown}
role="separator"
aria-orientation={isH ? 'vertical' : 'horizontal'}
aria-valuenow={Math.round(split)}
aria-valuemin={minSize}
aria-valuemax={100 - minSize}
tabIndex={0}
style={{
[isH ? 'width' : 'height']: '4px',
cursor: isH ? 'col-resize' : 'row-resize',
background: '#e2e8f0',
flexShrink: 0,
}}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
{children[1]}
</div>
</div>
);
}That role="separator" with aria-valuenow, aria-valuemin, and aria-valuemax is not optional. Screen readers announce the divider position and let users know they can interact with it. Without those attributes you're shipping an inaccessible component. The tabIndex={0} makes it focusable so keyboard users can tab to it.
Worth noting: the 4px divider hit target is too small for touch. On mobile you want at least 44px touch area (the iOS HIG minimum) while keeping the visual divider thin. The trick is an invisible pseudo-element or an absolutely-positioned touch zone over a thin visual line — or just bump the handle to 8px and style it differently on small screens.
One more thing — persist the split size. After all that work setting up drag, nothing is more annoying than your layout resetting on every page load. A useEffect writing to localStorage and an initializer reading from it makes the experience feel polished in about ten lines.
Using react-resizable-panels (The Fast Path)
react-resizable-panels by Bryan Vaughn is the library Shadcn chose as the backbone of its Resizable component in v0.8.0, and it shows — the API is genuinely good. You get imperative refs, persist-on-collapse, keyboard support, and both pixel and percentage constraints out of the box.
npm install react-resizable-panelsimport {
PanelGroup,
Panel,
PanelResizeHandle,
} from 'react-resizable-panels';
export function CodeEditorLayout() {
return (
<PanelGroup direction="horizontal" className="h-screen">
<Panel defaultSize={25} minSize={15}>
<aside className="h-full bg-gray-900 text-white p-4">
File Explorer
</aside>
</Panel>
<PanelResizeHandle className="w-1 bg-gray-700 hover:bg-indigo-500 transition-colors" />
<Panel defaultSize={50}>
<main className="h-full bg-gray-950 text-white p-4">
Editor
</main>
</Panel>
<PanelResizeHandle className="w-1 bg-gray-700 hover:bg-indigo-500 transition-colors" />
<Panel defaultSize={25} minSize={15}>
<div className="h-full bg-gray-900 text-white p-4">
Preview
</div>
</Panel>
</PanelGroup>
);
}That handles three panels — file tree, editor, preview — with drag handles, keyboard support, and minimum sizes enforced automatically. The PanelResizeHandle renders a focusable element with all the correct ARIA attributes already wired. You don't have to think about it.
In practice, reach for react-resizable-panels unless you have a specific reason not to. It's 7 kB gzipped, has no peer dependencies beyond React, and handles the obscure cases you don't want to discover yourself in a bug report six months post-launch. The from-scratch approach is educational and useful when you need ultra-tight bundle control or very custom behavior.
Persisting Layout State and Collapsible Panels
Static panel sizes that reset on every navigation are a UX failure. Users resize panels because they have a preference — respect it. react-resizable-panels gives you autoSaveId which writes to localStorage automatically. Pass a unique string per layout and you're done.
<PanelGroup direction="horizontal" autoSaveId="dashboard-layout">
{/* panels */}
</PanelGroup>Collapsible panels are different from minimum-size panels. Collapse means the panel goes to 0 (or a custom collapsed size) and can be toggled back open. This is your sidebar toggle, your terminal drawer, your preview pane. The library handles it with collapsible, collapsedSize, and onCollapse props on <Panel>, plus the ImperativePanelHandle ref for programmatic control.
import { useRef } from 'react';
import { Panel, PanelGroup, PanelResizeHandle, ImperativePanelHandle } from 'react-resizable-panels';
export function SidebarLayout({ children }: { children: React.ReactNode }) {
const sidebarRef = useRef<ImperativePanelHandle>(null);
const toggleSidebar = () => {
const sidebar = sidebarRef.current;
if (!sidebar) return;
sidebar.isCollapsed() ? sidebar.expand() : sidebar.collapse();
};
return (
<div className="flex flex-col h-screen">
<header className="p-3 border-b flex items-center gap-2">
<button onClick={toggleSidebar} aria-label="Toggle sidebar">
☰
</button>
</header>
<PanelGroup direction="horizontal" className="flex-1" autoSaveId="sidebar-layout">
<Panel
ref={sidebarRef}
defaultSize={20}
minSize={15}
collapsible
collapsedSize={0}
>
<nav className="h-full bg-gray-100 dark:bg-gray-900 p-4">Sidebar</nav>
</Panel>
<PanelResizeHandle className="w-1 bg-gray-200 dark:bg-gray-700" />
<Panel>{children}</Panel>
</PanelGroup>
</div>
);
}Quick aside: if you want a collapse animation — the panel sliding away rather than snapping — you need to handle that yourself, because the library doesn't ship CSS transitions for panel size changes. Wrap the panel content in a container with overflow: hidden and transition: width 200ms ease, and update a CSS variable via the panel's onResize callback. It's roughly 15 extra lines but makes the sidebar feel polished.
Nested Panels and Multi-Axis Layouts
Real apps need more than a single split. VS Code has a vertical split for panels (editor vs terminal) and a horizontal split for editors side-by-side. You get nested layouts by nesting PanelGroup components — an outer horizontal group contains a panel that itself contains a vertical PanelGroup.
export function IDELayout() {
return (
<PanelGroup direction="horizontal" className="h-screen" autoSaveId="ide">
{/* Left sidebar */}
<Panel defaultSize={18} minSize={12} collapsible collapsedSize={0}>
<div className="h-full bg-gray-900 text-gray-200 text-sm p-3">Files</div>
</Panel>
<PanelResizeHandle className="w-px bg-gray-700" />
{/* Center: editor on top, terminal below */}
<Panel>
<PanelGroup direction="vertical" autoSaveId="ide-center">
<Panel defaultSize={70}>
<div className="h-full bg-gray-950 text-white p-4">Editor</div>
</Panel>
<PanelResizeHandle className="h-px bg-gray-700" />
<Panel defaultSize={30} minSize={10} collapsible collapsedSize={0}>
<div className="h-full bg-gray-900 text-green-400 font-mono p-3 text-sm">
Terminal
</div>
</Panel>
</PanelGroup>
</Panel>
<PanelResizeHandle className="w-px bg-gray-700" />
{/* Right panel */}
<Panel defaultSize={25} minSize={15} collapsible collapsedSize={0}>
<div className="h-full bg-gray-900 text-gray-200 p-4">Preview</div>
</Panel>
</PanelGroup>
);
}That composes to a full IDE-like layout with five separately resizable regions — left sidebar, editor, terminal, right panel — each with its own collapse behavior and minimum size. Each PanelGroup gets its own autoSaveId so all five sizes persist independently across sessions.
Look, nesting is powerful but adds cognitive overhead. Limit nesting to two levels in most apps. If you find yourself at three nested PanelGroup components, step back and ask whether the layout could be simplified — or whether some panels should be toggled with CSS visibility instead of being true resizable regions.
That said, the layout above pairs extremely well with expressive UI styling. If you want the panels to have a dark, high-contrast feel, check out Empire UI's cyberpunk or neobrutalism component sets — they include panel and card variants that drop straight into this kind of layout without any visual friction.
Keyboard Accessibility and Touch Support
Drag-to-resize is intuitive with a mouse and feels natural on touch. Keyboard users are a different story. The ARIA spec for a role="separator" that is focusable and adjustable says: arrow keys should move the divider, Home and End should jump to extremes, and Enter should reset to the default position. react-resizable-panels implements all of this by default — you get it for free.
If you're rolling your own, add a onKeyDown handler to the divider element. A common step size is 1% per arrow key press, with 10% per Page Up/Down. Here's the core of it:
const handleKeyDown = (e: React.KeyboardEvent) => {
const step = e.shiftKey ? 10 : 1;
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
setSplit(s => Math.max(minSize, s - step));
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
setSplit(s => Math.min(100 - minSize, s + step));
} else if (e.key === 'Home') {
setSplit(minSize);
} else if (e.key === 'End') {
setSplit(100 - minSize);
}
};Touch support is mostly handled by pointer events — pointermove fires on touch just like on mouse. The main gap is the 44px minimum touch target for the handle. On screens narrower than 768px consider either hiding the resize handle entirely (collapse the secondary panel by default instead) or making the handle visually larger. Forcing a mobile user to precisely hit a 4px-wide handle is a dead end.
Worth noting: the cursor CSS property on your drag handle needs to match the direction. Horizontal splits get col-resize, vertical splits get row-resize. It sounds obvious but it's easy to hard-code the wrong one and ship it. Test with both directions if you support both.
Integrating Resizable Panels With Your UI System
Resizable panels are structural — they define the skeleton of a page. Everything inside them inherits their width and height, so your child components need to handle flexible dimensions gracefully. Avoid hard-coded pixel widths inside panels; use width: 100% and height: 100% or flex-1 so content fills whatever space the panel provides.
Scroll behavior deserves attention. Each panel should be independently scrollable when its content overflows. Set overflow: auto (or overflow-y: auto if you only want vertical scroll) on the panel's direct content container — not on the panel wrapper itself, which the library manages. Mixing overflow: hidden on the panel with overflow: auto on the inner content is the pattern that works reliably across browsers in 2026.
If you're using Empire UI's component library, the glassmorphism components and gradient generator work especially well as panel headers or status bars. A glass-effect header at the top of a resizable panel — 48px tall, backdrop-blur-md, bg-white/10 — adds visual hierarchy without cluttering the layout. The panel content stays scrollable beneath it with a sticky header.
For apps that need theme support across the layout, Empire UI's style tokens handle dark/light mode consistently. The panel dividers, background fills, and scrollbar styling all adapt automatically when you switch themes — you're not stuck manually theming every border. Browse the component library to see how the tokens map across the different visual styles.
FAQ
react-resizable-panels is the current default choice — it's what Shadcn UI wraps, it's 7 kB gzipped, and it handles keyboard accessibility, persistence, and collapse out of the box. Roll your own only if you need ultra-custom behavior or have strict bundle constraints.
Pass autoSaveId="your-layout-id" to <PanelGroup> and the library writes sizes to localStorage automatically. For the from-scratch approach, use a useEffect to write to localStorage on size change and initialize state from it.
The divider element needs role="separator", tabIndex={0}, and aria-valuenow/min/max attributes. Arrow keys should adjust the split, with Home/End jumping to the extremes. react-resizable-panels wires all of this automatically.
Yes — nest <PanelGroup> components inside <Panel> elements. An outer horizontal group can contain a panel that itself holds a vertical group. Give each PanelGroup its own autoSaveId so the sizes persist independently.