deepmoon

Split Pane

Drag the divider, or focus it and use arrow keys (Shift for larger steps, Home/End for min/max).

Pane A
Pane B
Top
Bottom

Installation

$ npx shadcn@latest add https://deepmoon.dev/r/split-pane.json

Props

SplitPaneProps
PropTypeRequired
children[React.ReactNode, React.ReactNode]Yes
defaultSizenumberNo
minSizenumberNo
maxSizenumberNo
orientation"horizontal" | "vertical"No
classNamestringNo

Code

split-pane.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export interface SplitPaneProps {
  children: [React.ReactNode, React.ReactNode];
  /** Percentage (0-100) of the first pane's size. */
  defaultSize?: number;
  minSize?: number;
  maxSize?: number;
  orientation?: "horizontal" | "vertical";
  className?: string;
}

/**
 * "horizontal" (default) = panes side by side, divider drags left/right.
 * "vertical" = panes stacked, divider drags up/down. The divider is a
 * role="separator" with a real hit area (w-3/h-3) wider than its 1px
 * visual line, and is keyboard-resizable via arrow keys once focused.
 */
export function SplitPane({
  children,
  defaultSize = 50,
  minSize = 15,
  maxSize = 85,
  orientation = "horizontal",
  className,
}: SplitPaneProps) {
  const [size, setSize] = React.useState(defaultSize);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const isHorizontal = orientation === "horizontal";

  const clamp = (n: number) => Math.min(maxSize, Math.max(minSize, n));

  const updateFromPointer = (clientX: number, clientY: number) => {
    const rect = containerRef.current?.getBoundingClientRect();
    if (!rect) return;
    const percent = isHorizontal
      ? ((clientX - rect.left) / rect.width) * 100
      : ((clientY - rect.top) / rect.height) * 100;
    setSize(clamp(percent));
  };

  return (
    <div ref={containerRef} className={cn("flex h-full w-full", isHorizontal ? "flex-row" : "flex-col", className)}>
      <div
        style={{ [isHorizontal ? "width" : "height"]: `${size}%` }}
        className="min-h-0 min-w-0 overflow-auto"
      >
        {children[0]}
      </div>
      <div
        role="separator"
        aria-orientation={isHorizontal ? "vertical" : "horizontal"}
        aria-valuenow={Math.round(size)}
        aria-valuemin={minSize}
        aria-valuemax={maxSize}
        aria-label="Resize panes"
        tabIndex={0}
        onPointerDown={(event) => event.currentTarget.setPointerCapture(event.pointerId)}
        onPointerMove={(event) => {
          if (event.buttons !== 1) return;
          updateFromPointer(event.clientX, event.clientY);
        }}
        onKeyDown={(event) => {
          const step = event.shiftKey ? 10 : 2;
          let next: number | null = null;
          if (isHorizontal && event.key === "ArrowLeft") next = size - step;
          else if (isHorizontal && event.key === "ArrowRight") next = size + step;
          else if (!isHorizontal && event.key === "ArrowUp") next = size - step;
          else if (!isHorizontal && event.key === "ArrowDown") next = size + step;
          else if (event.key === "Home") next = minSize;
          else if (event.key === "End") next = maxSize;
          if (next === null) return;
          event.preventDefault();
          setSize(clamp(next));
        }}
        className={cn(
          "group relative shrink-0 touch-none outline-none",
          isHorizontal ? "w-3 cursor-col-resize" : "h-3 cursor-row-resize",
        )}
      >
        <span
          aria-hidden="true"
          className={cn(
            "absolute inset-0 m-auto bg-border transition-colors group-focus-visible:bg-ring",
            isHorizontal ? "h-full w-px" : "h-px w-full",
          )}
        />
      </div>
      <div className="min-h-0 min-w-0 flex-1 overflow-auto">{children[1]}</div>
    </div>
  );
}