Resizable Panels
Two panels with a draggable divider; arrow keys resize, double-click resets.
Inbox
Design review notes
Invoice #1042
Welcome to the team
Design review notes
Drag the handle, use the arrow keys, or double-click it to reset. Both panels keep their own scroll.
components/ui/resizable-panels.tsx
"use client";
import { GripVertical } from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
type ResizablePanelsProps = {
left: ReactNode;
right: ReactNode;
/** Starting width of the left panel, in percent */
initial?: number;
min?: number;
max?: number;
};
/** Two panels with a draggable divider. Arrow keys resize too; double-click resets. */
export function ResizablePanels({ left, right, initial = 40, min = 20, max = 80 }: ResizablePanelsProps) {
const [size, setSize] = useState(initial);
const [dragging, setDragging] = useState(false);
const root = useRef<HTMLDivElement>(null);
const clamp = (v: number) => Math.max(min, Math.min(max, v));
return (
<div ref={root} className={`flex h-72 w-full max-w-3xl overflow-hidden rounded-2xl border border-zinc-200 bg-white ${dragging ? "select-none" : ""}`}>
<div className="min-w-0 overflow-auto" style={{ width: `${size}%` }}>
{left}
</div>
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={Math.round(size)}
aria-valuemin={min}
aria-valuemax={max}
aria-label="Resize panels"
tabIndex={0}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}}
onPointerMove={(e) => {
if (!dragging || !root.current) return;
const r = root.current.getBoundingClientRect();
setSize(clamp(((e.clientX - r.left) / r.width) * 100));
}}
onPointerUp={() => setDragging(false)}
onDoubleClick={() => setSize(initial)}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") setSize((s) => clamp(s - 2));
if (e.key === "ArrowRight") setSize((s) => clamp(s + 2));
}}
className={`group relative w-1.5 shrink-0 cursor-col-resize touch-none outline-none transition-colors ${dragging ? "bg-blue-500" : "bg-zinc-100 hover:bg-blue-400 focus-visible:bg-blue-500"}`}
>
<span className="absolute left-1/2 top-1/2 grid h-8 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-md border border-zinc-200 bg-white shadow-sm">
<GripVertical className="size-3 text-zinc-400" />
</span>
</div>
<div className="min-w-0 flex-1 overflow-auto">{right}</div>
</div>
);
}
export default function Demo() {
return (
<ResizablePanels
left={
<div className="p-5">
<p className="text-xs font-medium uppercase tracking-wider text-zinc-400">Inbox</p>
{["Design review notes", "Invoice #1042", "Welcome to the team"].map((t) => (
<p key={t} className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-700">
{t}
</p>
))}
</div>
}
right={
<div className="p-5">
<p className="font-semibold text-zinc-900">Design review notes</p>
<p className="mt-2 text-sm leading-relaxed text-zinc-500">Drag the handle, use the arrow keys, or double-click it to reset. Both panels keep their own scroll.</p>
</div>
}
/>
);
}
Edit with AIPro
Describe a change to Resizable Panels and get the updated code. The live preview above stays as the original.
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/resizable-panels.tsxcomponents/ui/resizable-panels.tsx"use client"; import { GripVertical } from "lucide-react"; import { useRef, useState, type ReactNode } from "react"; type ResizablePanelsProps = { left: ReactNode; right: ReactNode; /** Starting width of the left panel, in percent */ initial?: number; min?: number; max?: number; }; /** Two panels with a draggable divider. Arrow keys resize too; double-click resets. */ export function ResizablePanels({ left, right, initial = 40, min = 20, max = 80 }: ResizablePanelsProps) { const [size, setSize] = useState(initial); const [dragging, setDragging] = useState(false); const root = useRef<HTMLDivElement>(null); const clamp = (v: number) => Math.max(min, Math.min(max, v)); return ( <div ref={root} className={`flex h-72 w-full max-w-3xl overflow-hidden rounded-2xl border border-zinc-200 bg-white ${dragging ? "select-none" : ""}`}> <div className="min-w-0 overflow-auto" style={{ width: `${size}%` }}> {left} </div> <div role="separator" aria-orientation="vertical" aria-valuenow={Math.round(size)} aria-valuemin={min} aria-valuemax={max} aria-label="Resize panels" tabIndex={0} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); setDragging(true); }} onPointerMove={(e) => { if (!dragging || !root.current) return; const r = root.current.getBoundingClientRect(); setSize(clamp(((e.clientX - r.left) / r.width) * 100)); }} onPointerUp={() => setDragging(false)} onDoubleClick={() => setSize(initial)} onKeyDown={(e) => { if (e.key === "ArrowLeft") setSize((s) => clamp(s - 2)); if (e.key === "ArrowRight") setSize((s) => clamp(s + 2)); }} className={`group relative w-1.5 shrink-0 cursor-col-resize touch-none outline-none transition-colors ${dragging ? "bg-blue-500" : "bg-zinc-100 hover:bg-blue-400 focus-visible:bg-blue-500"}`} > <span className="absolute left-1/2 top-1/2 grid h-8 w-4 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-md border border-zinc-200 bg-white shadow-sm"> <GripVertical className="size-3 text-zinc-400" /> </span> </div> <div className="min-w-0 flex-1 overflow-auto">{right}</div> </div> ); } export default function Demo() { return ( <ResizablePanels left={ <div className="p-5"> <p className="text-xs font-medium uppercase tracking-wider text-zinc-400">Inbox</p> {["Design review notes", "Invoice #1042", "Welcome to the team"].map((t) => ( <p key={t} className="mt-3 truncate rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-700"> {t} </p> ))} </div> } right={ <div className="p-5"> <p className="font-semibold text-zinc-900">Design review notes</p> <p className="mt-2 text-sm leading-relaxed text-zinc-500">Drag the handle, use the arrow keys, or double-click it to reset. Both panels keep their own scroll.</p> </div> } /> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.