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.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useEffect,6 useRef,7 useState,8 Children,9} from "react";10import { motion, useReducedMotion } from "motion/react";11import { cn } from "@/lib/utils";12 13export type SplitPaneProps = {14 children: [React.ReactNode, React.ReactNode];15 defaultRatio?: number;16 minRatio?: number;17 maxRatio?: number;18 direction?: "horizontal" | "vertical";19 className?: string;20};21 22const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };23 24function clamp(n: number, lo: number, hi: number) {25 return Math.min(hi, Math.max(lo, n));26}27 28export function SplitPane({29 children,30 defaultRatio = 0.5,31 minRatio = 0.2,32 maxRatio = 0.8,33 direction = "horizontal",34 className,35}: SplitPaneProps) {36 const reduce = useReducedMotion();37 const rootRef = useRef<HTMLDivElement>(null);38 const [ratio, setRatio] = useState(defaultRatio);39 const [live, setLive] = useState<number | null>(null);40 const liveRef = useRef<number | null>(null);41 const dragging = useRef(false);42 const [a, b] = Children.toArray(children);43 44 const display = live ?? ratio;45 const isH = direction === "horizontal";46 47 const ratioFromPointer = useCallback(48 (clientX: number, clientY: number) => {49 const el = rootRef.current;50 if (!el) return ratio;51 const rect = el.getBoundingClientRect();52 const raw = isH53 ? (clientX - rect.left) / rect.width54 : (clientY - rect.top) / rect.height;55 const overshoot = 0.06;56 return clamp(raw, minRatio - overshoot, maxRatio + overshoot);57 },58 [isH, maxRatio, minRatio, ratio],59 );60 61 const settle = useCallback(62 (next: number) => {63 setRatio(clamp(next, minRatio, maxRatio));64 liveRef.current = null;65 setLive(null);66 },67 [maxRatio, minRatio],68 );69 70 useEffect(() => {71 const onMove = (e: PointerEvent) => {72 if (!dragging.current) return;73 const next = ratioFromPointer(e.clientX, e.clientY);74 liveRef.current = next;75 setLive(next);76 };77 const onUp = () => {78 if (!dragging.current) return;79 dragging.current = false;80 if (liveRef.current != null) settle(liveRef.current);81 };82 window.addEventListener("pointermove", onMove);83 window.addEventListener("pointerup", onUp);84 return () => {85 window.removeEventListener("pointermove", onMove);86 window.removeEventListener("pointerup", onUp);87 };88 }, [ratioFromPointer, settle]);89 90 const startDrag = (e: React.PointerEvent) => {91 e.preventDefault();92 dragging.current = true;93 const next = ratioFromPointer(e.clientX, e.clientY);94 liveRef.current = next;95 setLive(next);96 };97 98 const nudge = (dir: -1 | 1) => {99 settle(ratio + dir * 0.04);100 };101 102 const firstStyle = isH103 ? { width: `${display * 100}%` }104 : { height: `${display * 100}%` };105 const secondStyle = isH106 ? { width: `${(1 - display) * 100}%` }107 : { height: `${(1 - display) * 100}%` };108 109 return (110 <div111 ref={rootRef}112 className={cn(113 "flex min-h-[12rem] w-full overflow-hidden rounded-[var(--radius-md)] ring-1 ring-[var(--color-rule)]",114 isH ? "flex-row" : "flex-col",115 className,116 )}117 >118 <motion.div119 className="min-h-0 min-w-0 overflow-auto bg-[var(--color-paper)]"120 animate={firstStyle}121 transition={122 live != null123 ? { duration: 0 }124 : reduce125 ? { duration: 0.1 }126 : SPRING127 }128 initial={false}129 >130 {a}131 </motion.div>132 <button133 type="button"134 aria-label="Resize panels"135 aria-orientation={isH ? "vertical" : "horizontal"}136 role="separator"137 tabIndex={0}138 onPointerDown={startDrag}139 onKeyDown={(e) => {140 if (isH) {141 if (e.key === "ArrowLeft") {142 e.preventDefault();143 nudge(-1);144 } else if (e.key === "ArrowRight") {145 e.preventDefault();146 nudge(1);147 }148 } else {149 if (e.key === "ArrowUp") {150 e.preventDefault();151 nudge(-1);152 } else if (e.key === "ArrowDown") {153 e.preventDefault();154 nudge(1);155 }156 }157 }}158 className={cn(159 "shrink-0 bg-[var(--color-paper-2)] outline-none",160 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] focus-visible:ring-inset",161 isH162 ? "w-3 cursor-col-resize touch-none"163 : "h-3 cursor-row-resize touch-none",164 )}165 >166 <span167 className={cn(168 "mx-auto block rounded-full bg-[var(--color-rule)]",169 isH ? "mt-[calc(50%-12px)] h-6 w-1" : "ml-[calc(50%-12px)] h-1 w-6",170 )}171 />172 </button>173 <motion.div174 className="min-h-0 min-w-0 overflow-auto bg-[var(--color-paper)]"175 animate={secondStyle}176 transition={177 live != null178 ? { duration: 0 }179 : reduce180 ? { duration: 0.1 }181 : SPRING182 }183 initial={false}184 >185 {b}186 </motion.div>187 </div>188 );189}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { SplitPane } from "@/components/ui/split-pane";2 3<SplitPane defaultRatio={0.4} minRatio={0.25} maxRatio={0.7}>4 {[<Sidebar key="a" />, <Main key="b" />]}5</SplitPane>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| children | [ReactNode, ReactNode] | Yes | — | Left/top and right/bottom panels. |
| defaultRatio | number | No | 0.5 | Initial first-panel fraction. |
| minRatio | number | No | 0.2 | Minimum first-panel fraction. |
| maxRatio | number | No | 0.8 | Maximum first-panel fraction. |
| direction | "horizontal" | "vertical" | No | "horizontal" | Split axis. |
Best Practices
- Pass exactly two children as a tuple/array.
- Persist
ratioyourself if the layout should survive navigation. - Prefer horizontal for master–detail; vertical for stacked editors.