{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "resizable-panes", "title": "Better Resizable Panes", "description": "Pixel-accurate resizable panes with drag handles, min/max constraints, flex pane behavior, and localStorage persistence. A better alternative to react-resizable-panels.", "dependencies": [ "react" ], "registryDependencies": [ "utils" ], "files": [ { "path": "registry/better-resizable-panes/resizable-panes.tsx", "content": "import React, {\n useRef,\n useCallback,\n useState,\n useEffect,\n type ReactNode,\n type CSSProperties,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useLocalStorage } from \"./use-local-storage\";\n\n// ---- Types ----\n\ntype Orientation = \"horizontal\" | \"vertical\";\n\ninterface PaneConfig {\n /** Unique id for this pane (required for localStorage persistence). */\n id: string;\n /** Default size as a percentage of the group (0-100). All panes should sum to ~100. */\n defaultSize: number;\n /** Minimum size in pixels. */\n minSize?: number;\n /** Maximum size in pixels. */\n maxSize?: number;\n /**\n * If true this pane absorbs remaining space when the container resizes,\n * instead of keeping its pixel size. At most one pane per group should be\n * flex. If none is marked, the last pane is treated as flex.\n */\n flex?: boolean;\n}\n\n// ---- Hook: useResizable ----\n\ninterface UseResizableOptions {\n /** Unique storage key for persisting sizes across sessions. */\n storageKey: string;\n orientation: Orientation;\n panes: PaneConfig[];\n}\n\ninterface UseResizableReturn {\n /** Current pixel size of each pane. */\n sizes: number[];\n /** Start a drag on the handle at `handleIndex` from pointer position `startPos`. */\n startDrag: (handleIndex: number, startPos: number) => void;\n /** Attach this ref to the outer container element so the hook can measure it. */\n containerRef: React.RefObject;\n /** Whether a drag is currently in progress. */\n dragging: boolean;\n}\n\nexport function useResizable({\n storageKey,\n orientation,\n panes,\n}: UseResizableOptions): UseResizableReturn {\n const containerRef = useRef(null);\n const [savedSizes, setSavedSizes] = useLocalStorage(\n `resizable-${storageKey}`,\n null,\n );\n const [sizes, setSizesState] = useState([]);\n const [dragging, setDragging] = useState(false);\n\n // Track which pane is flex (absorbs remaining space)\n const flexIndex = panes.findIndex((p) => p.flex);\n const effectiveFlexIndex = flexIndex >= 0 ? flexIndex : panes.length - 1;\n\n // Initialize sizes from saved values or defaults\n const initSizes = useCallback(\n (containerSize: number): number[] => {\n if (savedSizes && savedSizes.length === panes.length) {\n const total = savedSizes.reduce((a, b) => a + b, 0);\n if (Math.abs(total - containerSize) < containerSize * 0.3) {\n const adjusted = [...savedSizes];\n const diff = containerSize - total;\n adjusted[effectiveFlexIndex] = Math.max(\n panes[effectiveFlexIndex]?.minSize ?? 50,\n adjusted[effectiveFlexIndex]! + diff,\n );\n return adjusted;\n }\n }\n return panes.map((p) => (p.defaultSize / 100) * containerSize);\n },\n [panes, savedSizes, effectiveFlexIndex],\n );\n\n // Measure container and init / update sizes\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n\n const measure = () => {\n const containerSize =\n orientation === \"horizontal\" ? el.offsetWidth : el.offsetHeight;\n if (containerSize <= 0) return;\n\n setSizesState((prev) => {\n if (prev.length === 0) {\n return initSizes(containerSize);\n }\n const nonFlexTotal = prev.reduce(\n (sum, s, i) => (i === effectiveFlexIndex ? sum : sum + s),\n 0,\n );\n const newFlexSize = Math.max(\n panes[effectiveFlexIndex]?.minSize ?? 50,\n containerSize - nonFlexTotal,\n );\n return prev.map((s, i) => (i === effectiveFlexIndex ? newFlexSize : s));\n });\n };\n\n measure();\n\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [orientation, panes.length, effectiveFlexIndex, initSizes]);\n\n // Drag logic\n const dragState = useRef<{\n handleIndex: number;\n startPos: number;\n startSizes: number[];\n } | null>(null);\n\n const startDrag = useCallback(\n (handleIndex: number, startPos: number) => {\n dragState.current = { handleIndex, startPos, startSizes: [...sizes] };\n setDragging(true);\n },\n [sizes],\n );\n\n useEffect(() => {\n if (!dragging) return;\n\n const onPointerMove = (e: PointerEvent) => {\n const ds = dragState.current;\n if (!ds) return;\n\n const pos = orientation === \"horizontal\" ? e.clientX : e.clientY;\n const delta = pos - ds.startPos;\n const i = ds.handleIndex;\n const j = i + 1;\n\n let newA = ds.startSizes[i]! + delta;\n let newB = ds.startSizes[j]! - delta;\n\n const minA = panes[i]?.minSize ?? 30;\n const minB = panes[j]?.minSize ?? 30;\n const maxA = panes[i]?.maxSize ?? Infinity;\n const maxB = panes[j]?.maxSize ?? Infinity;\n\n if (newA < minA) {\n newB += newA - minA;\n newA = minA;\n }\n if (newB < minB) {\n newA += newB - minB;\n newB = minB;\n }\n if (newA > maxA) {\n newB += newA - maxA;\n newA = maxA;\n }\n if (newB > maxB) {\n newA += newB - maxB;\n newB = maxB;\n }\n\n newA = Math.max(minA, Math.min(maxA, newA));\n newB = Math.max(minB, Math.min(maxB, newB));\n\n setSizesState((prev) => {\n const next = [...prev];\n next[i] = newA;\n next[j] = newB;\n return next;\n });\n };\n\n const onPointerUp = () => {\n dragState.current = null;\n setDragging(false);\n setSizesState((current) => {\n setSavedSizes(current);\n return current;\n });\n };\n\n document.addEventListener(\"pointermove\", onPointerMove);\n document.addEventListener(\"pointerup\", onPointerUp);\n return () => {\n document.removeEventListener(\"pointermove\", onPointerMove);\n document.removeEventListener(\"pointerup\", onPointerUp);\n };\n }, [dragging, orientation, panes, setSavedSizes]);\n\n return { sizes, startDrag, containerRef, dragging };\n}\n\n// ---- Components ----\n\ninterface ResizableGroupProps {\n /** Unique key for persisting pane sizes to localStorage. */\n storageKey: string;\n orientation: Orientation;\n /** Pane configurations - order must match children. */\n panes: PaneConfig[];\n children: ReactNode;\n className?: string;\n}\n\nexport function ResizableGroup({\n storageKey,\n orientation,\n panes,\n children,\n className,\n}: ResizableGroupProps) {\n const { sizes, startDrag, containerRef, dragging } = useResizable({\n storageKey,\n orientation,\n panes,\n });\n\n const isHorizontal = orientation === \"horizontal\";\n const childArray = React.Children.toArray(children);\n\n const elements: ReactNode[] = [];\n let paneIdx = 0;\n\n for (let i = 0; i < childArray.length; i++) {\n const child = childArray[i];\n\n if (React.isValidElement(child) && child.type === ResizableHandle) {\n const handleIndex = paneIdx - 1;\n elements.push(\n React.cloneElement(\n child as React.ReactElement,\n {\n key: `handle-${handleIndex}`,\n _orientation: orientation,\n _onDragStart: (startPos: number) => startDrag(handleIndex, startPos),\n },\n ),\n );\n } else {\n const size = sizes[paneIdx];\n const style: CSSProperties =\n size != null\n ? {\n [isHorizontal ? \"width\" : \"height\"]: size,\n flexShrink: 0,\n flexGrow: 0,\n overflow: \"hidden\",\n }\n : { flex: 1 };\n\n elements.push(\n
\n {child}\n
,\n );\n paneIdx++;\n }\n }\n\n return (\n \n {elements}\n \n );\n}\n\n// ---- Handle ----\n\ninterface HandleInternalProps {\n _orientation?: Orientation;\n _onDragStart?: (startPos: number) => void;\n}\n\ninterface ResizableHandleProps {\n className?: string;\n children?: ReactNode;\n}\n\nexport function ResizableHandle({\n className,\n children,\n ...internal\n}: ResizableHandleProps & HandleInternalProps) {\n const {\n _orientation: orientation = \"horizontal\",\n _onDragStart: onDragStart,\n } = internal;\n const isHorizontal = orientation === \"horizontal\";\n\n const handlePointerDown = useCallback(\n (e: React.PointerEvent) => {\n e.preventDefault();\n onDragStart?.(isHorizontal ? e.clientX : e.clientY);\n },\n [onDragStart, isHorizontal],\n );\n\n return (\n \n {children ?? }\n \n );\n}\n\nfunction DefaultGrip({ orientation }: { orientation: Orientation }) {\n const isHorizontal = orientation === \"horizontal\";\n return (\n \n \n
\n
\n
\n
\n
\n );\n}\n\n// ---- Pane (semantic wrapper) ----\n\ninterface ResizablePaneProps {\n children: ReactNode;\n className?: string;\n}\n\nexport function ResizablePane({ children, className }: ResizablePaneProps) {\n return
{children}
;\n}\n", "type": "registry:ui", "target": "components/ui/resizable-panes.tsx" }, { "path": "registry/better-resizable-panes/use-local-storage.ts", "content": "import { useState, useCallback } from \"react\";\n\n/**\n * React hook that syncs state with localStorage.\n * Reads the initial value from localStorage (falling back to the provided default),\n * and writes back on every update.\n */\nexport function useLocalStorage(\n key: string,\n initialValue: T,\n): [T, (value: T | ((prev: T) => T)) => void] {\n const [stored, setStored] = useState(() => {\n try {\n const raw = localStorage.getItem(key);\n return raw != null ? JSON.parse(raw) : initialValue;\n } catch {\n return initialValue;\n }\n });\n\n const setValue = useCallback(\n (value: T | ((prev: T) => T)) => {\n setStored((prev) => {\n const next = value instanceof Function ? value(prev) : value;\n try {\n localStorage.setItem(key, JSON.stringify(next));\n } catch {\n /* quota exceeded - silently ignore */\n }\n return next;\n });\n },\n [key],\n );\n\n return [stored, setValue];\n}\n", "type": "registry:hook", "target": "hooks/use-local-storage.ts" } ], "type": "registry:ui" }