Overview

Two panels and a divider. Drag tracks 1:1 with rubber-band past min/max; release springs into bounds. Arrow keys nudge. Reduced motion skips the settle spring.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/split-pane.json

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/split-pane.tsx
tsx
"use client";
import React, {  useCallback,  useEffect,  useRef,  useState,  Children,} from "react";import { motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type SplitPaneProps = {  children: [React.ReactNode, React.ReactNode];  defaultRatio?: number;  minRatio?: number;  maxRatio?: number;  direction?: "horizontal" | "vertical";  className?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };
function clamp(n: number, lo: number, hi: number) {  return Math.min(hi, Math.max(lo, n));}
export function SplitPane({  children,  defaultRatio = 0.5,  minRatio = 0.2,  maxRatio = 0.8,  direction = "horizontal",  className,}: SplitPaneProps) {  const reduce = useReducedMotion();  const rootRef = useRef<HTMLDivElement>(null);  const [ratio, setRatio] = useState(defaultRatio);  const [live, setLive] = useState<number | null>(null);  const liveRef = useRef<number | null>(null);  const dragging = useRef(false);  const [a, b] = Children.toArray(children);
  const display = live ?? ratio;  const isH = direction === "horizontal";
  const ratioFromPointer = useCallback(    (clientX: number, clientY: number) => {      const el = rootRef.current;      if (!el) return ratio;      const rect = el.getBoundingClientRect();      const raw = isH        ? (clientX - rect.left) / rect.width        : (clientY - rect.top) / rect.height;      const overshoot = 0.06;      return clamp(raw, minRatio - overshoot, maxRatio + overshoot);    },    [isH, maxRatio, minRatio, ratio],  );
  const settle = useCallback(    (next: number) => {      setRatio(clamp(next, minRatio, maxRatio));      liveRef.current = null;      setLive(null);    },    [maxRatio, minRatio],  );
  useEffect(() => {    const onMove = (e: PointerEvent) => {      if (!dragging.current) return;      const next = ratioFromPointer(e.clientX, e.clientY);      liveRef.current = next;      setLive(next);    };    const onUp = () => {      if (!dragging.current) return;      dragging.current = false;      if (liveRef.current != null) settle(liveRef.current);    };    window.addEventListener("pointermove", onMove);    window.addEventListener("pointerup", onUp);    return () => {      window.removeEventListener("pointermove", onMove);      window.removeEventListener("pointerup", onUp);    };  }, [ratioFromPointer, settle]);
  const startDrag = (e: React.PointerEvent) => {    e.preventDefault();    dragging.current = true;    const next = ratioFromPointer(e.clientX, e.clientY);    liveRef.current = next;    setLive(next);  };
  const nudge = (dir: -1 | 1) => {    settle(ratio + dir * 0.04);  };
  const firstStyle = isH    ? { width: `${display * 100}%` }    : { height: `${display * 100}%` };  const secondStyle = isH    ? { width: `${(1 - display) * 100}%` }    : { height: `${(1 - display) * 100}%` };
  return (    <div      ref={rootRef}      className={cn(        "flex min-h-[12rem] w-full overflow-hidden rounded-[var(--radius-md)] ring-1 ring-[var(--color-rule)]",        isH ? "flex-row" : "flex-col",        className,      )}    >      <motion.div        className="min-h-0 min-w-0 overflow-auto bg-[var(--color-paper)]"        animate={firstStyle}        transition={          live != null            ? { duration: 0 }            : reduce              ? { duration: 0.1 }              : SPRING        }        initial={false}      >        {a}      </motion.div>      <button        type="button"        aria-label="Resize panels"        aria-orientation={isH ? "vertical" : "horizontal"}        role="separator"        tabIndex={0}        onPointerDown={startDrag}        onKeyDown={(e) => {          if (isH) {            if (e.key === "ArrowLeft") {              e.preventDefault();              nudge(-1);            } else if (e.key === "ArrowRight") {              e.preventDefault();              nudge(1);            }          } else {            if (e.key === "ArrowUp") {              e.preventDefault();              nudge(-1);            } else if (e.key === "ArrowDown") {              e.preventDefault();              nudge(1);            }          }        }}        className={cn(          "shrink-0 bg-[var(--color-paper-2)] outline-none",          "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-inset",          isH            ? "w-3 cursor-col-resize touch-none"            : "h-3 cursor-row-resize touch-none",        )}      >        <span          className={cn(            "mx-auto block rounded-full bg-[var(--color-rule)]",            isH ? "mt-[calc(50%-12px)] h-6 w-1" : "ml-[calc(50%-12px)] h-1 w-6",          )}        />      </button>      <motion.div        className="min-h-0 min-w-0 overflow-auto bg-[var(--color-paper)]"        animate={secondStyle}        transition={          live != null            ? { duration: 0 }            : reduce              ? { duration: 0.1 }              : SPRING        }        initial={false}      >        {b}      </motion.div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SplitPane } from "@/components/ui/split-pane";
<SplitPane defaultRatio={0.4} minRatio={0.25} maxRatio={0.7}>  {[<Sidebar key="a" />, <Main key="b" />]}</SplitPane>

Props

PropTypeRequiredDefaultDescription
children[ReactNode, ReactNode]YesLeft/top and right/bottom panels.
defaultRationumberNo0.5Initial first-panel fraction.
minRationumberNo0.2Minimum first-panel fraction.
maxRationumberNo0.8Maximum first-panel fraction.
direction"horizontal" | "vertical"No"horizontal"Split axis.

Best Practices

  1. Pass exactly two children as a tuple/array.
  2. Persist ratio yourself if the layout should survive navigation.
  3. Prefer horizontal for master–detail; vertical for stacked editors.