All componentslayout
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.jsonProps
SplitPaneProps
| Prop | Type | Required |
|---|---|---|
| children | [React.ReactNode, React.ReactNode] | Yes |
| defaultSize | number | No |
| minSize | number | No |
| maxSize | number | No |
| orientation | "horizontal" | "vertical" | No |
| className | string | No |
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>
);
}