Layout

Split Pane

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.

Install

npx shadcn@latest add @paragon/split-pane

split-pane.tsx

"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>
  );
}