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.

Edit with AIPro

Describe a change to Resizable Panels and get the updated code. The live preview above stays as the original.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/resizable-panels.tsx

    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>
          }
        />
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.