Resizable Panels
Drag-resizable panels for dashboards, IDEs, and editors. Nest groups for complex layouts like sidebar | editor/terminal.
Component
export function App() {
return <div>Hello</div>;
}$ pnpm dev
"use client";
import {
Children,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Direction = "horizontal" | "vertical";
interface ResizablePanelsProps {
children: React.ReactNode;
className?: string;
defaultSizes?: number[];
direction?: Direction;
maxSize?: number;
minSize?: number;
resizeHandleSize?: number;
}
function getEqualSizes(count: number): number[] {
const size = 100 / count;
return Array.from({ length: count }, () => size);
}
const DEFAULT_HANDLE_SIZE = 24;
export function ResizablePanels({
children,
className,
direction = "horizontal",
defaultSizes,
minSize = 10,
maxSize = 90,
resizeHandleSize = DEFAULT_HANDLE_SIZE,
}: ResizablePanelsProps) {
const panels = Children.toArray(children);
const count = panels.length;
const [sizes, setSizes] = useState<number[]>(() => {
if (defaultSizes && defaultSizes.length === count) {
const sum = defaultSizes.reduce((a, b) => a + b, 0);
return defaultSizes.map((s) => (s / sum) * 100);
}
return getEqualSizes(count);
});
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{
index: number;
startSizes: number[];
startPos: number;
} | null>(null);
const clamp = useCallback(
(value: number) =>
Math.max(minSize, Math.min(maxSize, value)),
[minSize, maxSize],
);
const handlePointerDown = useCallback(
(index: number) => (e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(
e.pointerId,
);
document.body.style.touchAction = "none";
draggingRef.current = {
index,
startPos:
direction === "horizontal"
? e.clientX
: e.clientY,
startSizes: [...sizes],
};
},
[sizes, direction],
);
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
const drag = draggingRef.current;
if (!drag) {
return;
}
if (!containerRef.current) {
return;
}
const pos =
direction === "horizontal" ? e.clientX : e.clientY;
const delta = pos - drag.startPos;
const rect =
containerRef.current.getBoundingClientRect();
const total =
direction === "horizontal"
? rect.width
: rect.height;
const deltaPercent = (delta / total) * 100;
const leftIdx = drag.index;
const rightIdx = drag.index + 1;
const leftStart = drag.startSizes[leftIdx] ?? 0;
const rightStart = drag.startSizes[rightIdx] ?? 0;
const newSizes = [...drag.startSizes];
let leftNew = leftStart + deltaPercent;
let rightNew = rightStart - deltaPercent;
leftNew = clamp(leftNew);
rightNew = clamp(rightNew);
const totalAdjacent = leftStart + rightStart;
const newTotal = leftNew + rightNew;
if (Math.abs(newTotal - totalAdjacent) > 0.01) {
const scale = totalAdjacent / newTotal;
leftNew *= scale;
rightNew *= scale;
}
newSizes[leftIdx] = leftNew;
newSizes[rightIdx] = rightNew;
setSizes(newSizes);
};
const handlePointerUp = () => {
draggingRef.current = null;
document.body.style.cursor = "";
document.body.style.touchAction = "";
document.body.style.userSelect = "";
};
window.addEventListener(
"pointermove",
handlePointerMove,
);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener(
"pointercancel",
handlePointerUp,
);
return () => {
window.removeEventListener(
"pointermove",
handlePointerMove,
);
window.removeEventListener(
"pointerup",
handlePointerUp,
);
window.removeEventListener(
"pointercancel",
handlePointerUp,
);
};
}, [direction, clamp]);
useEffect(() => {
if (sizes.length !== count) {
setSizes(getEqualSizes(count));
}
}, [count, sizes.length]);
const isHorizontal = direction === "horizontal";
const elements: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
elements.push(
<div
key={`panel-${i}`}
className="flex min-h-0 min-w-0 overflow-auto"
style={{ flex: `${sizes[i] ?? 1} 1 0` }}
>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{panels[i]}
</div>
</div>,
);
if (i < count - 1) {
elements.push(
<div
key={`handle-${i}`}
className={cn(
"flex shrink-0 touch-none items-center justify-center bg-border transition-colors hover:bg-primary/20",
isHorizontal
? "cursor-col-resize"
: "cursor-row-resize",
)}
style={{
...(isHorizontal
? { width: resizeHandleSize }
: { height: resizeHandleSize }),
touchAction: "none",
}}
onPointerDown={handlePointerDown(i)}
>
<div
className={cn(
"rounded-full bg-muted-foreground/30",
isHorizontal ? "h-8 w-0.5" : "h-0.5 w-8",
)}
/>
</div>,
);
}
}
return (
<div
ref={containerRef}
className={cn(
"flex h-full min-h-0 w-full min-w-0 overflow-hidden",
isHorizontal ? "flex-row" : "flex-col",
className,
)}
>
{elements}
</div>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/resizable-panels.json"1. Copy the component file
"use client";
import {
Children,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type Direction = "horizontal" | "vertical";
interface ResizablePanelsProps {
children: React.ReactNode;
className?: string;
defaultSizes?: number[];
direction?: Direction;
maxSize?: number;
minSize?: number;
resizeHandleSize?: number;
}
function getEqualSizes(count: number): number[] {
const size = 100 / count;
return Array.from({ length: count }, () => size);
}
const DEFAULT_HANDLE_SIZE = 24;
export function ResizablePanels({
children,
className,
direction = "horizontal",
defaultSizes,
minSize = 10,
maxSize = 90,
resizeHandleSize = DEFAULT_HANDLE_SIZE,
}: ResizablePanelsProps) {
const panels = Children.toArray(children);
const count = panels.length;
const [sizes, setSizes] = useState<number[]>(() => {
if (defaultSizes && defaultSizes.length === count) {
const sum = defaultSizes.reduce((a, b) => a + b, 0);
return defaultSizes.map((s) => (s / sum) * 100);
}
return getEqualSizes(count);
});
const containerRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<{
index: number;
startSizes: number[];
startPos: number;
} | null>(null);
const clamp = useCallback(
(value: number) =>
Math.max(minSize, Math.min(maxSize, value)),
[minSize, maxSize],
);
const handlePointerDown = useCallback(
(index: number) => (e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(
e.pointerId,
);
document.body.style.touchAction = "none";
draggingRef.current = {
index,
startPos:
direction === "horizontal"
? e.clientX
: e.clientY,
startSizes: [...sizes],
};
},
[sizes, direction],
);
useEffect(() => {
const handlePointerMove = (e: PointerEvent) => {
const drag = draggingRef.current;
if (!drag) {
return;
}
if (!containerRef.current) {
return;
}
const pos =
direction === "horizontal" ? e.clientX : e.clientY;
const delta = pos - drag.startPos;
const rect =
containerRef.current.getBoundingClientRect();
const total =
direction === "horizontal"
? rect.width
: rect.height;
const deltaPercent = (delta / total) * 100;
const leftIdx = drag.index;
const rightIdx = drag.index + 1;
const leftStart = drag.startSizes[leftIdx] ?? 0;
const rightStart = drag.startSizes[rightIdx] ?? 0;
const newSizes = [...drag.startSizes];
let leftNew = leftStart + deltaPercent;
let rightNew = rightStart - deltaPercent;
leftNew = clamp(leftNew);
rightNew = clamp(rightNew);
const totalAdjacent = leftStart + rightStart;
const newTotal = leftNew + rightNew;
if (Math.abs(newTotal - totalAdjacent) > 0.01) {
const scale = totalAdjacent / newTotal;
leftNew *= scale;
rightNew *= scale;
}
newSizes[leftIdx] = leftNew;
newSizes[rightIdx] = rightNew;
setSizes(newSizes);
};
const handlePointerUp = () => {
draggingRef.current = null;
document.body.style.cursor = "";
document.body.style.touchAction = "";
document.body.style.userSelect = "";
};
window.addEventListener(
"pointermove",
handlePointerMove,
);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener(
"pointercancel",
handlePointerUp,
);
return () => {
window.removeEventListener(
"pointermove",
handlePointerMove,
);
window.removeEventListener(
"pointerup",
handlePointerUp,
);
window.removeEventListener(
"pointercancel",
handlePointerUp,
);
};
}, [direction, clamp]);
useEffect(() => {
if (sizes.length !== count) {
setSizes(getEqualSizes(count));
}
}, [count, sizes.length]);
const isHorizontal = direction === "horizontal";
const elements: React.ReactNode[] = [];
for (let i = 0; i < count; i++) {
elements.push(
<div
key={`panel-${i}`}
className="flex min-h-0 min-w-0 overflow-auto"
style={{ flex: `${sizes[i] ?? 1} 1 0` }}
>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{panels[i]}
</div>
</div>,
);
if (i < count - 1) {
elements.push(
<div
key={`handle-${i}`}
className={cn(
"flex shrink-0 touch-none items-center justify-center bg-border transition-colors hover:bg-primary/20",
isHorizontal
? "cursor-col-resize"
: "cursor-row-resize",
)}
style={{
...(isHorizontal
? { width: resizeHandleSize }
: { height: resizeHandleSize }),
touchAction: "none",
}}
onPointerDown={handlePointerDown(i)}
>
<div
className={cn(
"rounded-full bg-muted-foreground/30",
isHorizontal ? "h-8 w-0.5" : "h-0.5 w-8",
)}
/>
</div>,
);
}
}
return (
<div
ref={containerRef}
className={cn(
"flex h-full min-h-0 w-full min-w-0 overflow-hidden",
isHorizontal ? "flex-row" : "flex-col",
className,
)}
>
{elements}
</div>
);
}2. Import and use
import { ResizablePanels } from "@/components/resizable-panels";
<div className="h-96">
<ResizablePanels
direction="horizontal"
defaultSizes={[25, 75]}
>
<div>Sidebar</div>
<div>Content</div>
</ResizablePanels>
</div>;Usage
ResizablePanels splits space between children with draggable handles. Each child is a panel. Nest ResizablePanels as a panel child to create complex layouts: different directions at each level let you build IDE-style (sidebar | editor | terminal), dashboards (sidebar | header/content), or multi-column UIs. Always give the root container a height (e.g. h-96) so panels can size correctly.
Import
Add the ResizablePanels import.
import { ResizablePanels } from "@/components/resizable-panels";Use
Wrap in a container with a defined height. Pass panels as children.
<div className="h-96">
<ResizablePanels direction="horizontal">
...
</ResizablePanels>
</div>;Nest for complex layouts
Nest ResizablePanels as a panel child for complex layouts.
<ResizablePanels>
<div>A</div>
<ResizablePanels direction="vertical">
...
</ResizablePanels>
</ResizablePanels>;Guidelines
- Give the root container a height (h-screen, h-96, etc.) so panels can size correctly.
- Nest ResizablePanels for complex layouts: each nested group can have its own direction.
- defaultSizes are proportional weights; [20, 80] means 20% and 80% of available space.
- Use minSize and maxSize to constrain panel dimensions (default 10–90%).
- Add overflow-auto to panel content that may overflow (e.g. file lists, code).
- Touch-friendly: 24px handle by default; use resizeHandleSize to adjust.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | — | Panel content. Each direct child becomes a resizable panel. Can be another ResizablePanels for nesting. |
| className | string | — | Additional CSS classes for the container. |
| direction | "horizontal" | "vertical" | "horizontal" | Layout direction: horizontal (side-by-side) or vertical (stacked). |
| defaultSizes | number[] | equal | Initial panel sizes as flex-grow weights. Values are proportional (e.g. [20, 80] ≈ 20%/80%). |
| minSize | number | 10 | Minimum panel size as percentage of the group. |
| maxSize | number | 90 | Maximum panel size as percentage of the group. |
| resizeHandleSize | number | 24 | Resize handle hit area in pixels. Use 24+ for touch-friendly targets. |
Examples
IDE layout
IDE-style: sidebar full height, right side split into editor (top) and terminal (bottom).
export function App() {
return <div>Hello</div>;
}$ pnpm dev
import { ResizablePanels } from "@/components/resizable-panels";
export function IdeLayout() {
return (
<div className="h-screen">
<ResizablePanels
direction="horizontal"
defaultSizes={[20, 80]}
>
<aside className="overflow-auto">
File explorer
</aside>
<ResizablePanels
direction="vertical"
defaultSizes={[70, 30]}
>
<main className="overflow-auto">Editor</main>
<footer className="overflow-auto">
Terminal
</footer>
</ResizablePanels>
</ResizablePanels>
</div>
);
}Dashboard layout
Dashboard: sidebar, then header + main content stacked on the right.
Date range, search, actions
import { ResizablePanels } from "@/components/resizable-panels";
export function DashboardLayout() {
return (
<div className="h-screen">
<ResizablePanels
direction="horizontal"
defaultSizes={[25, 75]}
>
<nav className="overflow-auto">Sidebar nav</nav>
<ResizablePanels
direction="vertical"
defaultSizes={[15, 85]}
>
<header className="overflow-auto">
Filters / toolbar
</header>
<main className="overflow-auto">
Content area
</main>
</ResizablePanels>
</ResizablePanels>
</div>
);
}Three-column layout
Three columns in a row. No nesting needed for simple horizontal splits.
import { ResizablePanels } from "@/components/resizable-panels";
export function ThreeColumnLayout() {
return (
<div className="h-96">
<ResizablePanels
direction="horizontal"
defaultSizes={[20, 50, 30]}
>
<aside>Left</aside>
<main>Center</main>
<aside>Right</aside>
</ResizablePanels>
</div>
);
}L-shaped layout
L-shaped: left panel spans full height; right side split vertically into top and bottom.
import { ResizablePanels } from "@/components/resizable-panels";
export function LShapedLayout() {
return (
<div className="h-96">
<ResizablePanels
direction="horizontal"
defaultSizes={[30, 70]}
>
<div className="flex items-center justify-center">
Sidebar (full height)
</div>
<ResizablePanels
direction="vertical"
defaultSizes={[50, 50]}
>
<div className="flex items-center justify-center">
Top
</div>
<div className="flex items-center justify-center">
Bottom
</div>
</ResizablePanels>
</ResizablePanels>
</div>
);
}