A resizable two-pane layout with a pointer-captured divider, min/max clamps, global drag cursor, keyboard resizing, and an animated double-click snap-reset.
npx shadcn@latest add @paragon/split-pane"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SplitPaneProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Exactly two panes. */
children: [React.ReactNode, React.ReactNode];
/** Initial size of the first pane, percent. Double-click resets to this. */
defaultSize?: number;
/** Smallest the first pane can get, percent. */
minSize?: number;
/** Largest the first pane can get, percent. */
maxSize?: number;
orientation?: "horizontal" | "vertical";
/** Fires with the committed size (percent) after a drag or key press. */
onSizeChange?: (size: number) => void;
}
/**
* A resizable two-pane layout.
*
* The divider captures the pointer, so dragging never loses the handle even
* when the cursor outruns it; the cursor and text selection are managed
* globally for the duration. Sizes are written straight to the DOM during
* the drag — no transition fights the pointer — while the double-click
* snap-reset re-enables a short grid-template transition so the panes glide
* home. The handle is a focusable `role="separator"`: arrow keys nudge by
* 2% (10% with Shift), Home/End jump to the clamps, Enter resets.
*/
export function SplitPane({
children,
defaultSize = 50,
minSize = 20,
maxSize = 80,
orientation = "horizontal",
onSizeChange,
className,
style,
...props
}: SplitPaneProps) {
const clamp = React.useCallback(
(value: number) => Math.min(maxSize, Math.max(minSize, value)),
[minSize, maxSize],
);
const containerRef = React.useRef<HTMLDivElement>(null);
const handleRef = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState(() => clamp(defaultSize));
const [dragging, setDragging] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
const animationTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
const dragState = React.useRef({ origin: 0, startSize: 0, size: 0 });
const horizontal = orientation === "horizontal";
React.useEffect(() => {
return () => {
if (animationTimeout.current) clearTimeout(animationTimeout.current);
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
};
}, []);
/** Direct DOM write during drag — bypasses React so nothing lags. */
const write = (next: number) => {
dragState.current.size = next;
containerRef.current?.style.setProperty("--split-size", `${next}%`);
handleRef.current?.setAttribute("aria-valuenow", String(Math.round(next)));
};
const commit = (next: number) => {
setSize(next);
onSizeChange?.(next);
};
const animateTo = (next: number) => {
if (animationTimeout.current) clearTimeout(animationTimeout.current);
setAnimating(true);
write(next);
commit(next);
animationTimeout.current = setTimeout(() => setAnimating(false), 300);
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
// Interrupt a running snap-reset: freeze where it is, no transition.
if (animationTimeout.current) clearTimeout(animationTimeout.current);
setAnimating(false);
event.currentTarget.setPointerCapture(event.pointerId);
dragState.current = {
origin: horizontal ? event.clientX : event.clientY,
startSize: size,
size,
};
setDragging(true);
document.body.style.cursor = horizontal ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
const container = containerRef.current;
if (!container) return;
const extent = horizontal
? container.getBoundingClientRect().width
: container.getBoundingClientRect().height;
if (extent === 0) return;
const delta =
((horizontal ? event.clientX : event.clientY) -
dragState.current.origin) /
extent;
write(clamp(dragState.current.startSize + delta * 100));
};
const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
commit(dragState.current.size);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const step = event.shiftKey ? 10 : 2;
const decreaseKey = horizontal ? "ArrowLeft" : "ArrowUp";
const increaseKey = horizontal ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (event.key === decreaseKey) next = clamp(size - step);
else if (event.key === increaseKey) next = clamp(size + step);
else if (event.key === "Home") next = minSize;
else if (event.key === "End") next = maxSize;
else if (event.key === "Enter") {
event.preventDefault();
animateTo(clamp(defaultSize));
return;
}
if (next === null) return;
event.preventDefault();
write(next);
commit(next);
};
const [first, second] = children;
return (
<div
ref={containerRef}
data-slot="split-pane"
data-orientation={orientation}
className={cn(
"grid overflow-hidden rounded-xl bg-card shadow-border",
horizontal
? "grid-cols-[var(--split-size)_1px_minmax(0,1fr)]"
: "grid-rows-[var(--split-size)_1px_minmax(0,1fr)]",
animating &&
!dragging &&
"transition-[grid-template-columns,grid-template-rows] duration-(--duration-base) ease-(--ease-out) motion-reduce:transition-none",
className,
)}
style={
{ "--split-size": `${size}%`, ...style } as React.CSSProperties
}
{...props}
>
<div data-slot="split-pane-first" className="min-h-0 min-w-0 overflow-auto">
{first}
</div>
<div
ref={handleRef}
role="separator"
tabIndex={0}
aria-orientation={horizontal ? "vertical" : "horizontal"}
aria-valuenow={Math.round(size)}
aria-valuemin={minSize}
aria-valuemax={maxSize}
aria-label="Resize panes"
data-dragging={dragging || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => animateTo(clamp(defaultSize))}
onKeyDown={onKeyDown}
className={cn(
"group/handle relative bg-border outline-none",
// ≥ 40px hit area straddling the hairline.
horizontal
? "cursor-col-resize after:absolute after:inset-y-0 after:-inset-x-1.5 after:content-['']"
: "cursor-row-resize after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']",
"touch-none",
)}
>
<span
aria-hidden
className={cn(
"pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-border transition-[background-color,box-shadow,opacity] duration-(--duration-quick) ease-out",
horizontal ? "h-8 w-1" : "h-1 w-8",
"group-hover/handle:bg-ring/60 group-focus-visible/handle:bg-ring",
"group-data-[dragging]/handle:bg-ring group-data-[dragging]/handle:shadow-[0_0_10px_2px_color-mix(in_oklab,var(--color-ring)_35%,transparent)]",
)}
/>
</div>
<div
data-slot="split-pane-second"
className="min-h-0 min-w-0 overflow-auto"
>
{second}
</div>
</div>
);
}