=>
+ typeof child === 'object' && child !== null && 'props' in child
+ );
+
+ const paneCount = paneElements.length;
+
+ // Calculate initial sizes, minSizes, and maxSizes
+ const getPaneSizes = useCallback(() => {
+ if (containerSize === 0) {
+ return {
+ sizes: new Array(paneCount).fill(0),
+ minSizes: new Array(paneCount).fill(0),
+ maxSizes: new Array(paneCount).fill(Infinity),
+ };
+ }
+
+ const sizes: number[] = [];
+ const minSizes: number[] = [];
+ const maxSizes: number[] = [];
+
+ paneElements.forEach((pane) => {
+ const { size, defaultSize, minSize = 0, maxSize = Infinity } = pane.props;
+
+ // Use controlled size if available, otherwise defaultSize, otherwise equal distribution
+ const paneSize = size ?? defaultSize ?? containerSize / paneCount;
+ sizes.push(convertToPixels(paneSize, containerSize));
+ minSizes.push(convertToPixels(minSize, containerSize));
+ maxSizes.push(
+ maxSize === Infinity ? Infinity : convertToPixels(maxSize, containerSize)
+ );
+ });
+
+ return { sizes, minSizes, maxSizes };
+ }, [containerSize, paneCount, paneElements]);
+
+ const { sizes: initialSizes, minSizes, maxSizes } = getPaneSizes();
+
+ const [paneSizes, setPaneSizes] = useState(initialSizes);
+
+ // Update sizes when container size changes
+ useEffect(() => {
+ if (containerSize === 0) return;
+
+ const { sizes } = getPaneSizes();
+
+ // Only update if we don't have valid sizes yet
+ if (paneSizes.every(s => s === 0) || paneSizes.length !== sizes.length) {
+ setPaneSizes(sizes);
+ } else {
+ // Distribute existing sizes proportionally
+ const totalPaneSize = paneSizes.reduce((sum, s) => sum + s, 0);
+ if (totalPaneSize !== containerSize && totalPaneSize > 0) {
+ const distributed = distributeSizes(paneSizes, containerSize);
+ setPaneSizes(distributed);
+ }
+ }
+ }, [containerSize, getPaneSizes, paneSizes]);
+
+ // Measure container size
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const updateSize = () => {
+ const rect = container.getBoundingClientRect();
+ const size = direction === 'horizontal' ? rect.width : rect.height;
+ setContainerSize(size);
+ };
+
+ updateSize();
+
+ const resizeObserver = new ResizeObserver(updateSize);
+ resizeObserver.observe(container);
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [direction]);
+
+ // Resizer hook
+ const { isDragging, currentSizes, handleMouseDown, handleTouchStart, handleTouchEnd } =
+ useResizer({
+ direction,
+ sizes: paneSizes,
+ minSizes,
+ maxSizes,
+ snapPoints,
+ snapTolerance,
+ step,
+ onResizeStart,
+ onResize: useCallback(
+ (newSizes: number[], event: ResizeEvent) => {
+ setPaneSizes(newSizes);
+ onResize?.(newSizes, event);
+ },
+ [onResize]
+ ),
+ onResizeEnd,
+ });
+
+ // Keyboard resize hook
+ const { handleKeyDown } = useKeyboardResize({
+ direction,
+ sizes: currentSizes,
+ minSizes,
+ maxSizes,
+ step,
+ onResize: useCallback(
+ (newSizes: number[], event: ResizeEvent) => {
+ setPaneSizes(newSizes);
+ onResize?.(newSizes, event);
+ },
+ [onResize]
+ ),
+ onResizeEnd,
+ });
+
+ // Container styles
+ const containerStyle: CSSProperties = {
+ display: 'flex',
+ flexDirection: direction === 'horizontal' ? 'row' : 'column',
+ height: '100%',
+ width: '100%',
+ overflow: 'hidden',
+ position: 'relative',
+ ...style,
+ };
+
+ const containerClassName = [DEFAULT_CLASSNAME, direction, className]
+ .filter(Boolean)
+ .join(' ');
+
+ // Render panes and dividers
+ const renderChildren = () => {
+ const elements: JSX.Element[] = [];
+
+ paneElements.forEach((pane, index) => {
+ const paneSize = currentSizes[index] ?? 0;
+
+ const paneStyle: CSSProperties = {
+ ...(direction === 'horizontal'
+ ? { width: `${paneSize}px`, height: '100%' }
+ : { height: `${paneSize}px`, width: '100%' }),
+ ...pane.props.style,
+ };
+
+ // Render pane
+ elements.push(
+
+ {pane.props.children}
+
+ );
+
+ // Render divider (except after last pane)
+ if (index < paneCount - 1) {
+ const DividerComponent = CustomDivider ?? Divider;
+
+ elements.push(
+
+ );
+ }
+ });
+
+ return elements;
+ };
+
+ return (
+
+ {containerSize > 0 && renderChildren()}
+
+ );
+}
diff --git a/v3/src/hooks/useKeyboardResize.ts b/v3/src/hooks/useKeyboardResize.ts
new file mode 100644
index 00000000..bd2cff9d
--- /dev/null
+++ b/v3/src/hooks/useKeyboardResize.ts
@@ -0,0 +1,138 @@
+import { useCallback } from 'react';
+import { Direction, ResizeEvent } from '../types';
+import { calculateDraggedSizes, clamp } from '../utils/calculations';
+import { announce, formatSizeForAnnouncement } from '../utils/accessibility';
+
+export interface UseKeyboardResizeOptions {
+ direction: Direction;
+ sizes: number[];
+ minSizes: number[];
+ maxSizes: number[];
+ step?: number | undefined;
+ largeStep?: number | undefined;
+ onResize?: ((sizes: number[], event: ResizeEvent) => void) | undefined;
+ onResizeEnd?: ((sizes: number[], event: ResizeEvent) => void) | undefined;
+}
+
+const DEFAULT_STEP = 10;
+const DEFAULT_LARGE_STEP = 50;
+
+export function useKeyboardResize(options: UseKeyboardResizeOptions) {
+ const {
+ direction,
+ sizes,
+ minSizes,
+ maxSizes,
+ step = DEFAULT_STEP,
+ largeStep = DEFAULT_LARGE_STEP,
+ onResize,
+ onResizeEnd,
+ } = options;
+
+ const handleKeyDown = useCallback(
+ (dividerIndex: number) => (e: React.KeyboardEvent) => {
+ const isHorizontal = direction === 'horizontal';
+ const moveKeys = isHorizontal
+ ? ['ArrowLeft', 'ArrowRight']
+ : ['ArrowUp', 'ArrowDown'];
+
+ if (
+ !moveKeys.includes(e.key) &&
+ e.key !== 'Home' &&
+ e.key !== 'End' &&
+ e.key !== 'Escape'
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+
+ let delta = 0;
+ let newSizes = [...sizes];
+
+ switch (e.key) {
+ case 'ArrowLeft':
+ case 'ArrowUp':
+ delta = -(e.shiftKey ? largeStep : step);
+ break;
+
+ case 'ArrowRight':
+ case 'ArrowDown':
+ delta = e.shiftKey ? largeStep : step;
+ break;
+
+ case 'Home':
+ // Minimize left/top pane
+ newSizes[dividerIndex] = minSizes[dividerIndex] ?? 0;
+ newSizes[dividerIndex + 1] =
+ (sizes[dividerIndex] ?? 0) + (sizes[dividerIndex + 1] ?? 0) - newSizes[dividerIndex];
+ break;
+
+ case 'End':
+ // Maximize left/top pane
+ const maxLeft = maxSizes[dividerIndex] ?? Infinity;
+ const minRight = minSizes[dividerIndex + 1] ?? 0;
+ const totalSize = (sizes[dividerIndex] ?? 0) + (sizes[dividerIndex + 1] ?? 0);
+
+ newSizes[dividerIndex] = Math.min(maxLeft, totalSize - minRight);
+ newSizes[dividerIndex + 1] = totalSize - newSizes[dividerIndex];
+ break;
+
+ case 'Escape':
+ // Could restore original size if we track it
+ return;
+ }
+
+ if (delta !== 0) {
+ newSizes = calculateDraggedSizes(
+ sizes,
+ dividerIndex,
+ delta,
+ minSizes,
+ maxSizes
+ );
+ }
+
+ // Ensure constraints are met
+ newSizes = newSizes.map((size, idx) =>
+ clamp(size, minSizes[idx] ?? 0, maxSizes[idx] ?? Infinity)
+ );
+
+ if (onResize) {
+ onResize(newSizes, {
+ sizes: newSizes,
+ source: 'keyboard',
+ originalEvent: e.nativeEvent,
+ });
+ }
+
+ if (onResizeEnd) {
+ onResizeEnd(newSizes, {
+ sizes: newSizes,
+ source: 'keyboard',
+ originalEvent: e.nativeEvent,
+ });
+ }
+
+ // Announce change to screen readers
+ const changedPaneIndex = delta > 0 ? dividerIndex : dividerIndex + 1;
+ announce(
+ `Pane ${changedPaneIndex + 1} resized to ${formatSizeForAnnouncement(
+ newSizes[changedPaneIndex] ?? 0
+ )}`
+ );
+ },
+ [
+ direction,
+ sizes,
+ minSizes,
+ maxSizes,
+ step,
+ largeStep,
+ onResize,
+ onResizeEnd,
+ ]
+ );
+
+ return { handleKeyDown };
+}
diff --git a/v3/src/hooks/usePaneSize.ts b/v3/src/hooks/usePaneSize.ts
new file mode 100644
index 00000000..27a97114
--- /dev/null
+++ b/v3/src/hooks/usePaneSize.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+import { Size } from '../types';
+import { convertToPixels } from '../utils/calculations';
+
+export interface UsePaneSizeOptions {
+ defaultSize?: Size;
+ size?: Size;
+ minSize?: Size;
+ maxSize?: Size;
+ containerSize: number;
+}
+
+export interface UsePaneSizeResult {
+ pixelSize: number;
+ minPixelSize: number;
+ maxPixelSize: number;
+ isControlled: boolean;
+}
+
+export function usePaneSize(options: UsePaneSizeOptions): UsePaneSizeResult {
+ const { defaultSize, size, minSize = 0, maxSize = Infinity, containerSize } = options;
+
+ const isControlled = size !== undefined;
+
+ // Convert sizes to pixels
+ const defaultPixelSize = defaultSize
+ ? convertToPixels(defaultSize, containerSize)
+ : containerSize / 2; // Default to 50%
+
+ const minPixelSize = convertToPixels(minSize, containerSize);
+ const maxPixelSize =
+ maxSize === Infinity ? Infinity : convertToPixels(maxSize, containerSize);
+
+ const [internalSize, setInternalSize] = useState(defaultPixelSize);
+
+ const pixelSize = isControlled
+ ? convertToPixels(size, containerSize)
+ : internalSize;
+
+ // Update internal size when defaultSize or containerSize changes (uncontrolled only)
+ useEffect(() => {
+ if (!isControlled) {
+ const newDefaultSize = defaultSize
+ ? convertToPixels(defaultSize, containerSize)
+ : containerSize / 2;
+ setInternalSize(newDefaultSize);
+ }
+ }, [defaultSize, containerSize, isControlled]);
+
+ return {
+ pixelSize,
+ minPixelSize,
+ maxPixelSize,
+ isControlled,
+ };
+}
diff --git a/v3/src/hooks/useResizer.ts b/v3/src/hooks/useResizer.ts
new file mode 100644
index 00000000..55cdaf31
--- /dev/null
+++ b/v3/src/hooks/useResizer.ts
@@ -0,0 +1,235 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Direction, ResizeEvent } from '../types';
+import { calculateDraggedSizes, snapToPoint, applyStep } from '../utils/calculations';
+
+export interface UseResizerOptions {
+ direction: Direction;
+ sizes: number[];
+ minSizes: number[];
+ maxSizes: number[];
+ snapPoints?: number[] | undefined;
+ snapTolerance?: number | undefined;
+ step?: number | undefined;
+ onResizeStart?: ((event: ResizeEvent) => void) | undefined;
+ onResize?: ((sizes: number[], event: ResizeEvent) => void) | undefined;
+ onResizeEnd?: ((sizes: number[], event: ResizeEvent) => void) | undefined;
+}
+
+export interface UseResizerResult {
+ isDragging: boolean;
+ currentSizes: number[];
+ handleMouseDown: (dividerIndex: number) => (e: React.MouseEvent) => void;
+ handleTouchStart: (dividerIndex: number) => (e: React.TouchEvent) => void;
+ handleTouchEnd: (e: React.TouchEvent) => void;
+}
+
+export function useResizer(options: UseResizerOptions): UseResizerResult {
+ const {
+ direction,
+ sizes,
+ minSizes,
+ maxSizes,
+ snapPoints = [],
+ snapTolerance = 10,
+ step,
+ onResizeStart,
+ onResize,
+ onResizeEnd,
+ } = options;
+
+ const [isDragging, setIsDragging] = useState(false);
+ const [currentSizes, setCurrentSizes] = useState(sizes);
+
+ const dragStateRef = useRef<{
+ dividerIndex: number;
+ startPosition: number;
+ startSizes: number[];
+ } | null>(null);
+
+ const rafRef = useRef(null);
+
+ // Update current sizes when prop sizes change
+ useEffect(() => {
+ if (!isDragging) {
+ setCurrentSizes(sizes);
+ }
+ }, [sizes, isDragging]);
+
+ const handleDrag = useCallback(
+ (clientX: number, clientY: number) => {
+ if (!dragStateRef.current) return;
+
+ const { dividerIndex, startPosition, startSizes } = dragStateRef.current;
+ const currentPosition = direction === 'horizontal' ? clientX : clientY;
+
+ let delta = currentPosition - startPosition;
+
+ // Apply step if specified
+ if (step) {
+ delta = applyStep(delta, step);
+ }
+
+ let newSizes = calculateDraggedSizes(
+ startSizes,
+ dividerIndex,
+ delta,
+ minSizes,
+ maxSizes
+ );
+
+ // Apply snap points
+ if (snapPoints.length > 0) {
+ newSizes = newSizes.map((size) =>
+ snapToPoint(size, snapPoints, snapTolerance)
+ );
+ }
+
+ setCurrentSizes(newSizes);
+
+ if (onResize) {
+ onResize(newSizes, {
+ sizes: newSizes,
+ source: 'mouse',
+ });
+ }
+ },
+ [direction, step, minSizes, maxSizes, snapPoints, snapTolerance, onResize]
+ );
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+
+ // Use RAF to throttle updates
+ if (rafRef.current) return;
+
+ rafRef.current = requestAnimationFrame(() => {
+ handleDrag(e.clientX, e.clientY);
+ rafRef.current = null;
+ });
+ },
+ [handleDrag]
+ );
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ e.preventDefault();
+
+ if (rafRef.current) return;
+
+ rafRef.current = requestAnimationFrame(() => {
+ const touch = e.touches[0];
+ if (touch) {
+ handleDrag(touch.clientX, touch.clientY);
+ }
+ rafRef.current = null;
+ });
+ },
+ [handleDrag]
+ );
+
+ const handleMouseUp = useCallback(() => {
+ if (!dragStateRef.current) return;
+
+ setIsDragging(false);
+
+ if (onResizeEnd) {
+ onResizeEnd(currentSizes, {
+ sizes: currentSizes,
+ source: 'mouse',
+ });
+ }
+
+ dragStateRef.current = null;
+
+ // Cancel any pending RAF
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current);
+ rafRef.current = null;
+ }
+ }, [currentSizes, onResizeEnd]);
+
+ const handleMouseDown = useCallback(
+ (dividerIndex: number) => (e: React.MouseEvent) => {
+ e.preventDefault();
+
+ const startPosition = direction === 'horizontal' ? e.clientX : e.clientY;
+
+ dragStateRef.current = {
+ dividerIndex,
+ startPosition,
+ startSizes: currentSizes,
+ };
+
+ setIsDragging(true);
+
+ if (onResizeStart) {
+ onResizeStart({
+ sizes: currentSizes,
+ source: 'mouse',
+ originalEvent: e.nativeEvent,
+ });
+ }
+ },
+ [direction, currentSizes, onResizeStart]
+ );
+
+ const handleTouchStart = useCallback(
+ (dividerIndex: number) => (e: React.TouchEvent) => {
+ const touch = e.touches[0];
+ if (!touch) return;
+
+ const startPosition = direction === 'horizontal' ? touch.clientX : touch.clientY;
+
+ dragStateRef.current = {
+ dividerIndex,
+ startPosition,
+ startSizes: currentSizes,
+ };
+
+ setIsDragging(true);
+
+ if (onResizeStart) {
+ onResizeStart({
+ sizes: currentSizes,
+ source: 'touch',
+ originalEvent: e.nativeEvent,
+ });
+ }
+ },
+ [direction, currentSizes, onResizeStart]
+ );
+
+ const handleTouchEnd = useCallback(
+ (e: React.TouchEvent) => {
+ e.preventDefault();
+ handleMouseUp();
+ },
+ [handleMouseUp]
+ );
+
+ // Set up global event listeners
+ useEffect(() => {
+ if (!isDragging) return;
+
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ document.addEventListener('touchmove', handleTouchMove, { passive: false });
+ document.addEventListener('touchend', handleMouseUp);
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ document.removeEventListener('touchmove', handleTouchMove);
+ document.removeEventListener('touchend', handleMouseUp);
+ };
+ }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove]);
+
+ return {
+ isDragging,
+ currentSizes,
+ handleMouseDown,
+ handleTouchStart,
+ handleTouchEnd,
+ };
+}
diff --git a/v3/src/index.ts b/v3/src/index.ts
new file mode 100644
index 00000000..0987b3e8
--- /dev/null
+++ b/v3/src/index.ts
@@ -0,0 +1,17 @@
+export { SplitPane } from './components/SplitPane';
+export { Pane } from './components/Pane';
+export { Divider } from './components/Divider';
+
+export type {
+ SplitPaneProps,
+ PaneProps,
+ DividerProps,
+ Direction,
+ Size,
+ ResizeEvent,
+ PaneState,
+} from './types';
+
+// Re-export hooks for advanced usage
+export { useResizer } from './hooks/useResizer';
+export { usePaneSize } from './hooks/usePaneSize';
diff --git a/v3/src/keyboard.ts b/v3/src/keyboard.ts
new file mode 100644
index 00000000..782385ad
--- /dev/null
+++ b/v3/src/keyboard.ts
@@ -0,0 +1,3 @@
+// Keyboard-specific exports
+export { useKeyboardResize } from './hooks/useKeyboardResize';
+export type { UseKeyboardResizeOptions } from './hooks/useKeyboardResize';
diff --git a/v3/src/persistence.ts b/v3/src/persistence.ts
new file mode 100644
index 00000000..baacc862
--- /dev/null
+++ b/v3/src/persistence.ts
@@ -0,0 +1,56 @@
+import { useEffect, useState } from 'react';
+
+export interface UsePersistenceOptions {
+ key: string;
+ storage?: Storage;
+ debounce?: number;
+}
+
+/**
+ * Hook for persisting pane sizes to localStorage/sessionStorage
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * const [sizes, setSizes] = usePersistence({ key: 'my-layout' });
+ *
+ * return (
+ *
+ * ...
+ * ...
+ *
+ * );
+ * }
+ * ```
+ */
+export function usePersistence(
+ options: UsePersistenceOptions
+): [number[], (sizes: number[]) => void] {
+ const { key, storage = localStorage, debounce = 300 } = options;
+
+ const [sizes, setSizes] = useState(() => {
+ try {
+ const stored = storage.getItem(key);
+ return stored ? JSON.parse(stored) : [];
+ } catch {
+ return [];
+ }
+ });
+
+ // Debounced save to storage
+ useEffect(() => {
+ if (sizes.length === 0) return;
+
+ const timeout = setTimeout(() => {
+ try {
+ storage.setItem(key, JSON.stringify(sizes));
+ } catch (error) {
+ console.warn('Failed to persist pane sizes:', error);
+ }
+ }, debounce);
+
+ return () => clearTimeout(timeout);
+ }, [sizes, key, storage, debounce]);
+
+ return [sizes, setSizes];
+}
diff --git a/v3/src/test/setup.ts b/v3/src/test/setup.ts
new file mode 100644
index 00000000..c940f0da
--- /dev/null
+++ b/v3/src/test/setup.ts
@@ -0,0 +1,53 @@
+import '@testing-library/jest-dom';
+
+// Mock ResizeObserver with callback support
+(globalThis as any).ResizeObserver = class ResizeObserver {
+ private callback: ResizeObserverCallback;
+
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback;
+ }
+
+ observe(target: Element) {
+ // Call callback async to simulate real ResizeObserver behavior
+ setTimeout(() => {
+ const mockEntry = {
+ target,
+ contentRect: {
+ width: 1024,
+ height: 768,
+ top: 0,
+ left: 0,
+ bottom: 768,
+ right: 1024,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ },
+ borderBoxSize: [],
+ contentBoxSize: [],
+ devicePixelContentBoxSize: [],
+ } as unknown as ResizeObserverEntry;
+
+ // Call callback with mock data
+ this.callback([mockEntry], this);
+ }, 0);
+ }
+
+ unobserve() {
+ // Mock implementation
+ }
+
+ disconnect() {
+ // Mock implementation
+ }
+};
+
+// Mock requestAnimationFrame
+(globalThis as any).requestAnimationFrame = (callback: FrameRequestCallback) => {
+ return setTimeout(callback, 0) as unknown as number;
+};
+
+(globalThis as any).cancelAnimationFrame = (id: number) => {
+ clearTimeout(id);
+};
diff --git a/v3/src/types/index.ts b/v3/src/types/index.ts
new file mode 100644
index 00000000..4d0fc395
--- /dev/null
+++ b/v3/src/types/index.ts
@@ -0,0 +1,136 @@
+import { CSSProperties, ReactNode } from 'react';
+
+export type Direction = 'horizontal' | 'vertical';
+
+export type Size = string | number;
+
+export interface ResizeEvent {
+ sizes: number[];
+ source: 'mouse' | 'touch' | 'keyboard';
+ originalEvent?: MouseEvent | TouchEvent | KeyboardEvent;
+}
+
+export interface SplitPaneProps {
+ /** Layout direction - horizontal means panes are side-by-side */
+ direction?: Direction;
+
+ /** Whether panes can be resized */
+ resizable?: boolean;
+
+ /** Snap points for auto-alignment (in pixels) */
+ snapPoints?: number[];
+
+ /** Snap tolerance in pixels */
+ snapTolerance?: number;
+
+ /** Step size for keyboard resize in pixels */
+ step?: number;
+
+ /** Called when resize starts */
+ onResizeStart?: (event: ResizeEvent) => void;
+
+ /** Called during resize - consider debouncing this */
+ onResize?: (sizes: number[], event: ResizeEvent) => void;
+
+ /** Called when resize ends */
+ onResizeEnd?: (sizes: number[], event: ResizeEvent) => void;
+
+ /** CSS class name */
+ className?: string;
+
+ /** Inline styles */
+ style?: CSSProperties;
+
+ /** Custom divider component */
+ divider?: React.ComponentType;
+
+ /** Custom divider styles */
+ dividerStyle?: CSSProperties;
+
+ /** Custom divider class name */
+ dividerClassName?: string;
+
+ /** Pane children */
+ children: ReactNode;
+}
+
+export interface PaneProps {
+ /** Initial size (uncontrolled mode) */
+ defaultSize?: Size;
+
+ /** Controlled size */
+ size?: Size;
+
+ /** Minimum size */
+ minSize?: Size;
+
+ /** Maximum size */
+ maxSize?: Size;
+
+ /** Whether this pane can collapse */
+ collapsible?: boolean;
+
+ /** Whether pane is collapsed (controlled) */
+ collapsed?: boolean;
+
+ /** Called when collapse state changes */
+ onCollapse?: (collapsed: boolean) => void;
+
+ /** CSS class name */
+ className?: string;
+
+ /** Inline styles */
+ style?: CSSProperties;
+
+ /** Pane content */
+ children: ReactNode;
+}
+
+export interface DividerProps {
+ /** Layout direction */
+ direction: Direction;
+
+ /** Index of this divider (0-based) */
+ index: number;
+
+ /** Whether divider is being dragged */
+ isDragging: boolean;
+
+ /** Whether divider can be interacted with */
+ disabled: boolean;
+
+ /** Mouse down handler */
+ onMouseDown: (e: React.MouseEvent) => void;
+
+ /** Touch start handler */
+ onTouchStart: (e: React.TouchEvent) => void;
+
+ /** Touch end handler */
+ onTouchEnd: (e: React.TouchEvent) => void;
+
+ /** Keyboard handler */
+ onKeyDown: (e: React.KeyboardEvent) => void;
+
+ /** CSS class name */
+ className?: string | undefined;
+
+ /** Inline styles */
+ style?: CSSProperties | undefined;
+
+ /** Current size values for ARIA */
+ currentSize?: number | undefined;
+ minSize?: number | undefined;
+ maxSize?: number | undefined;
+
+ /** Custom content */
+ children?: ReactNode | undefined;
+}
+
+export interface PaneState {
+ size: number;
+ minSize: number;
+ maxSize: number;
+ defaultSize: number;
+ collapsible: boolean;
+ collapsed: boolean;
+}
diff --git a/v3/src/utils/accessibility.ts b/v3/src/utils/accessibility.ts
new file mode 100644
index 00000000..ac0f69a6
--- /dev/null
+++ b/v3/src/utils/accessibility.ts
@@ -0,0 +1,54 @@
+/**
+ * Announce a message to screen readers
+ */
+export function announce(message: string, priority: 'polite' | 'assertive' = 'polite'): void {
+ const announcement = document.createElement('div');
+ announcement.setAttribute('role', 'status');
+ announcement.setAttribute('aria-live', priority);
+ announcement.setAttribute('aria-atomic', 'true');
+ announcement.style.position = 'absolute';
+ announcement.style.left = '-10000px';
+ announcement.style.width = '1px';
+ announcement.style.height = '1px';
+ announcement.style.overflow = 'hidden';
+ announcement.textContent = message;
+
+ document.body.appendChild(announcement);
+
+ // Remove after announcement
+ setTimeout(() => {
+ document.body.removeChild(announcement);
+ }, 1000);
+}
+
+/**
+ * Format size for screen reader announcement
+ */
+export function formatSizeForAnnouncement(size: number): string {
+ if (size < 1000) {
+ return `${Math.round(size)} pixels`;
+ }
+ return `${Math.round(size / 10) / 100} thousand pixels`;
+}
+
+/**
+ * Generate accessible label for divider
+ */
+export function getDividerLabel(
+ index: number,
+ direction: 'horizontal' | 'vertical'
+): string {
+ const orientation = direction === 'horizontal' ? 'vertical' : 'horizontal';
+ return `${orientation} divider ${index + 1}`;
+}
+
+/**
+ * Get keyboard instructions for divider
+ */
+export function getKeyboardInstructions(direction: 'horizontal' | 'vertical'): string {
+ const keys = direction === 'horizontal'
+ ? 'left and right arrow keys'
+ : 'up and down arrow keys';
+
+ return `Use ${keys} to resize. Hold Shift for larger steps. Press Home or End to minimize or maximize.`;
+}
diff --git a/v3/src/utils/calculations.test.ts b/v3/src/utils/calculations.test.ts
new file mode 100644
index 00000000..7a5b3d01
--- /dev/null
+++ b/v3/src/utils/calculations.test.ts
@@ -0,0 +1,107 @@
+import { describe, it, expect } from 'vitest';
+import {
+ convertToPixels,
+ clamp,
+ snapToPoint,
+ distributeSizes,
+ calculateDraggedSizes,
+ applyStep,
+} from './calculations';
+
+describe('convertToPixels', () => {
+ it('converts percentage to pixels', () => {
+ expect(convertToPixels('50%', 1000)).toBe(500);
+ expect(convertToPixels('25%', 800)).toBe(200);
+ });
+
+ it('converts px string to number', () => {
+ expect(convertToPixels('100px', 1000)).toBe(100);
+ expect(convertToPixels('250px', 500)).toBe(250);
+ });
+
+ it('returns number as is', () => {
+ expect(convertToPixels(100, 1000)).toBe(100);
+ expect(convertToPixels(350, 500)).toBe(350);
+ });
+
+ it('handles invalid strings', () => {
+ expect(convertToPixels('invalid', 1000)).toBe(0);
+ });
+});
+
+describe('clamp', () => {
+ it('clamps value between min and max', () => {
+ expect(clamp(50, 0, 100)).toBe(50);
+ expect(clamp(-10, 0, 100)).toBe(0);
+ expect(clamp(150, 0, 100)).toBe(100);
+ });
+});
+
+describe('snapToPoint', () => {
+ it('snaps to nearest point within tolerance', () => {
+ expect(snapToPoint(105, [100, 200, 300], 10)).toBe(100);
+ expect(snapToPoint(195, [100, 200, 300], 10)).toBe(200);
+ });
+
+ it('does not snap if outside tolerance', () => {
+ expect(snapToPoint(120, [100, 200, 300], 10)).toBe(120);
+ });
+});
+
+describe('distributeSizes', () => {
+ it('distributes sizes proportionally', () => {
+ const currentSizes = [300, 700];
+ const newContainerSize = 500;
+ const result = distributeSizes(currentSizes, newContainerSize);
+
+ expect(result[0]).toBe(150); // 300/1000 * 500
+ expect(result[1]).toBe(350); // 700/1000 * 500
+ });
+
+ it('distributes equally when current sizes are zero', () => {
+ const currentSizes = [0, 0, 0];
+ const newContainerSize = 900;
+ const result = distributeSizes(currentSizes, newContainerSize);
+
+ expect(result).toEqual([300, 300, 300]);
+ });
+});
+
+describe('calculateDraggedSizes', () => {
+ it('calculates new sizes after drag', () => {
+ const sizes = [300, 700];
+ const result = calculateDraggedSizes(sizes, 0, 50, [100, 100], [500, 900]);
+
+ expect(result[0]).toBe(350);
+ expect(result[1]).toBe(650);
+ });
+
+ it('respects minimum constraints', () => {
+ const sizes = [300, 700];
+ const result = calculateDraggedSizes(sizes, 0, -250, [100, 100], [500, 900]);
+
+ expect(result[0]).toBe(100); // Cannot go below min
+ expect(result[1]).toBe(900);
+ });
+
+ it('respects maximum constraints', () => {
+ const sizes = [300, 700];
+ const result = calculateDraggedSizes(sizes, 0, 300, [100, 100], [500, 900]);
+
+ expect(result[0]).toBe(500); // Cannot exceed max
+ expect(result[1]).toBe(500);
+ });
+});
+
+describe('applyStep', () => {
+ it('applies step-based resizing', () => {
+ expect(applyStep(23, 10)).toBe(20);
+ expect(applyStep(27, 10)).toBe(30);
+ expect(applyStep(-23, 10)).toBe(-20);
+ });
+
+ it('returns delta unchanged if step is 0 or negative', () => {
+ expect(applyStep(23, 0)).toBe(23);
+ expect(applyStep(23, -5)).toBe(23);
+ });
+});
diff --git a/v3/src/utils/calculations.ts b/v3/src/utils/calculations.ts
new file mode 100644
index 00000000..f64fdfaf
--- /dev/null
+++ b/v3/src/utils/calculations.ts
@@ -0,0 +1,124 @@
+import { Size } from '../types';
+
+/**
+ * Convert a size value (string or number) to pixels
+ * @param size - Size value (e.g., "50%", "200px", 300)
+ * @param containerSize - Container size in pixels
+ * @returns Size in pixels
+ */
+export function convertToPixels(size: Size, containerSize: number): number {
+ if (typeof size === 'number') {
+ return size;
+ }
+
+ if (size.endsWith('%')) {
+ const percentage = parseFloat(size);
+ return (percentage / 100) * containerSize;
+ }
+
+ if (size.endsWith('px')) {
+ return parseFloat(size);
+ }
+
+ // Try to parse as number
+ const parsed = parseFloat(size);
+ return isNaN(parsed) ? 0 : parsed;
+}
+
+/**
+ * Constrain a value between min and max
+ */
+export function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+/**
+ * Snap a value to the nearest snap point if within tolerance
+ */
+export function snapToPoint(
+ value: number,
+ snapPoints: number[],
+ tolerance: number
+): number {
+ for (const point of snapPoints) {
+ if (Math.abs(value - point) <= tolerance) {
+ return point;
+ }
+ }
+ return value;
+}
+
+/**
+ * Distribute sizes proportionally when container size changes
+ */
+export function distributeSizes(
+ currentSizes: number[],
+ newContainerSize: number
+): number[] {
+ const totalCurrentSize = currentSizes.reduce((sum, size) => sum + size, 0);
+
+ if (totalCurrentSize === 0) {
+ // Equal distribution
+ const equalSize = newContainerSize / currentSizes.length;
+ return currentSizes.map(() => equalSize);
+ }
+
+ // Proportional distribution
+ return currentSizes.map(
+ (size) => (size / totalCurrentSize) * newContainerSize
+ );
+}
+
+/**
+ * Calculate new sizes after a divider drag
+ */
+export function calculateDraggedSizes(
+ sizes: number[],
+ dividerIndex: number,
+ delta: number,
+ minSizes: number[],
+ maxSizes: number[]
+): number[] {
+ const newSizes = [...sizes];
+
+ const leftSize = sizes[dividerIndex] ?? 0;
+ const rightSize = sizes[dividerIndex + 1] ?? 0;
+ const leftMin = minSizes[dividerIndex] ?? 0;
+ const leftMax = maxSizes[dividerIndex] ?? Infinity;
+ const rightMin = minSizes[dividerIndex + 1] ?? 0;
+ const rightMax = maxSizes[dividerIndex + 1] ?? Infinity;
+
+ // Calculate new sizes for the two panes around the divider
+ let newLeftSize = clamp(leftSize + delta, leftMin, leftMax);
+ let newRightSize = clamp(rightSize - delta, rightMin, rightMax);
+
+ // Check if we hit constraints
+ const leftDelta = newLeftSize - leftSize;
+ const rightDelta = rightSize - newRightSize;
+
+ // Use the smaller delta to ensure both panes respect constraints
+ const actualDelta = Math.min(Math.abs(leftDelta), Math.abs(rightDelta));
+
+ if (delta > 0) {
+ newLeftSize = leftSize + actualDelta;
+ newRightSize = rightSize - actualDelta;
+ } else {
+ newLeftSize = leftSize - actualDelta;
+ newRightSize = rightSize + actualDelta;
+ }
+
+ newSizes[dividerIndex] = newLeftSize;
+ newSizes[dividerIndex + 1] = newRightSize;
+
+ return newSizes;
+}
+
+/**
+ * Apply step-based resizing
+ */
+export function applyStep(delta: number, step: number): number {
+ if (step <= 0) return delta;
+
+ const steps = Math.round(delta / step);
+ return steps * step;
+}
diff --git a/v3/styles.css b/v3/styles.css
new file mode 100644
index 00000000..eaf40ded
--- /dev/null
+++ b/v3/styles.css
@@ -0,0 +1,117 @@
+/**
+ * Default styles for react-split-pane v3
+ *
+ * You can import these styles or create your own.
+ */
+
+.split-pane {
+ position: relative;
+ overflow: hidden;
+}
+
+.split-pane-pane {
+ position: relative;
+ overflow: auto;
+}
+
+.split-pane-divider {
+ position: relative;
+ z-index: 1;
+ box-sizing: border-box;
+ background-clip: padding-box;
+ transition: background-color 0.2s ease;
+}
+
+/* Horizontal layout (vertical divider) */
+.split-pane-divider.horizontal {
+ width: 11px;
+ margin: 0 -5px;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ cursor: col-resize;
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.split-pane-divider.horizontal:hover {
+ background-color: rgba(0, 0, 0, 0.15);
+ border-left-color: rgba(0, 0, 0, 0.1);
+ border-right-color: rgba(0, 0, 0, 0.1);
+}
+
+.split-pane-divider.horizontal:focus {
+ outline: 2px solid #2196f3;
+ outline-offset: -2px;
+ background-color: rgba(33, 150, 243, 0.1);
+}
+
+/* Vertical layout (horizontal divider) */
+.split-pane-divider.vertical {
+ height: 11px;
+ margin: -5px 0;
+ border-top: 5px solid transparent;
+ border-bottom: 5px solid transparent;
+ cursor: row-resize;
+ background-color: rgba(0, 0, 0, 0.05);
+}
+
+.split-pane-divider.vertical:hover {
+ background-color: rgba(0, 0, 0, 0.15);
+ border-top-color: rgba(0, 0, 0, 0.1);
+ border-bottom-color: rgba(0, 0, 0, 0.1);
+}
+
+.split-pane-divider.vertical:focus {
+ outline: 2px solid #2196f3;
+ outline-offset: -2px;
+ background-color: rgba(33, 150, 243, 0.1);
+}
+
+/* Disabled state */
+.split-pane-divider:focus:not(:focus-visible) {
+ outline: none;
+}
+
+/* Dark theme support */
+@media (prefers-color-scheme: dark) {
+ .split-pane-divider.horizontal,
+ .split-pane-divider.vertical {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ .split-pane-divider.horizontal:hover,
+ .split-pane-divider.vertical:hover {
+ background-color: rgba(255, 255, 255, 0.15);
+ }
+
+ .split-pane-divider.horizontal:hover {
+ border-left-color: rgba(255, 255, 255, 0.1);
+ border-right-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .split-pane-divider.vertical:hover {
+ border-top-color: rgba(255, 255, 255, 0.1);
+ border-bottom-color: rgba(255, 255, 255, 0.1);
+ }
+}
+
+/* Minimal theme (1px divider) */
+.split-pane-divider.minimal.horizontal {
+ width: 1px;
+ margin: 0;
+ border: none;
+ background-color: rgba(0, 0, 0, 0.12);
+}
+
+.split-pane-divider.minimal.vertical {
+ height: 1px;
+ margin: 0;
+ border: none;
+ background-color: rgba(0, 0, 0, 0.12);
+}
+
+@media (prefers-color-scheme: dark) {
+ .split-pane-divider.minimal.horizontal,
+ .split-pane-divider.minimal.vertical {
+ background-color: rgba(255, 255, 255, 0.12);
+ }
+}
diff --git a/v3/tsconfig.json b/v3/tsconfig.json
new file mode 100644
index 00000000..e0d588f4
--- /dev/null
+++ b/v3/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "**/*.test.tsx", "**/*.test.ts"]
+}
diff --git a/v3/vitest.config.ts b/v3/vitest.config.ts
new file mode 100644
index 00000000..db0f2c68
--- /dev/null
+++ b/v3/vitest.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig } from 'vitest/config';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/test/setup.ts',
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'json', 'html', 'lcov'],
+ exclude: [
+ 'node_modules/',
+ 'src/test/',
+ '**/*.test.{ts,tsx}',
+ '**/*.stories.tsx',
+ 'dist/',
+ ],
+ thresholds: {
+ lines: 90,
+ functions: 90,
+ branches: 85,
+ statements: 90,
+ },
+ },
+ },
+});