Nested React Splitter

A flexible React splitter layout with an independent sidebar and stacked workspace panels.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
'use client';

import {
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from 'react';

type Orientation = 'horizontal' | 'vertical';

type ResizablePanelsProps = {
  orientation: Orientation;
  firstPanel: ReactNode;
  secondPanel: ReactNode;
  defaultSize?: number;
  minSize?: number;
  ariaLabel?: string;
  showHandle?: boolean;
  className?: string;
};

function ResizablePanels({
  orientation,
  firstPanel,
  secondPanel,
  defaultSize = 50,
  minSize = 20,
  ariaLabel = 'Resize panels',
  showHandle = true,
  className = '',
}: ResizablePanelsProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const draggingRef = useRef(false);
  const [isActive, setIsActive] = useState(false);
  const firstPanelId = useId();
  const secondPanelId = useId();
  const isHorizontal = orientation === 'horizontal';
  const safeMinSize = Math.min(49, Math.max(0, minSize));
  const clamp = (value: number) =>
    Math.min(100 - safeMinSize, Math.max(safeMinSize, value));
  const [size, setSize] = useState(() => clamp(defaultSize));

  const resizeFromPointer = (clientX: number, clientY: number) => {
    const bounds = rootRef.current?.getBoundingClientRect();
    if (!bounds) return;

    const position = isHorizontal ? clientX - bounds.left : clientY - bounds.top;
    const total = isHorizontal ? bounds.width : bounds.height;
    setSize(clamp((position / total) * 100));
  };

  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
    draggingRef.current = true;
    setIsActive(true);
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    if (draggingRef.current) resizeFromPointer(event.clientX, event.clientY);
  };

  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
    draggingRef.current = false;
    setIsActive(false);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
  };

  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const decreaseKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
    const increaseKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
    const step = event.shiftKey ? 10 : 2;

    if (event.key === decreaseKey || event.key === increaseKey) {
      event.preventDefault();
      setSize((value) =>
        clamp(value + (event.key === increaseKey ? step : -step)),
      );
    } else if (event.key === 'Home') {
      event.preventDefault();
      setSize(safeMinSize);
    } else if (event.key === 'End') {
      event.preventDefault();
      setSize(100 - safeMinSize);
    }
  };

  return (
    <div
      ref={rootRef}
      className={[
        'relative flex overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm',
        isHorizontal ? 'flex-row' : 'flex-col',
        className,
      ]
        .filter(Boolean)
        .join(' ')}
    >
      <div
        id={firstPanelId}
        className='min-h-0 min-w-0 shrink-0 overflow-auto'
        style={{ flexBasis: `${size}%` }}
      >
        {firstPanel}
      </div>

      <div
        role='separator'
        tabIndex={0}
        aria-label={ariaLabel}
        aria-controls={`${firstPanelId} ${secondPanelId}`}
        aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
        aria-valuemin={safeMinSize}
        aria-valuemax={100 - safeMinSize}
        aria-valuenow={Math.round(size)}
        onDoubleClick={() => setSize(clamp(defaultSize))}
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onPointerCancel={handlePointerUp}
        onLostPointerCapture={() => {
          draggingRef.current = false;
          setIsActive(false);
        }}
        className={[
          'absolute z-10 touch-none bg-transparent outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-gray-300',
          isHorizontal
            ? 'bottom-0 top-0 w-3 -translate-x-1/2 cursor-col-resize'
            : 'left-0 right-0 h-3 -translate-y-1/2 cursor-row-resize',
        ].join(' ')}
        style={isHorizontal ? { left: `${size}%` } : { top: `${size}%` }}
      >
        <span
          aria-hidden='true'
          className={[
            isActive ? 'absolute bg-indigo-400' : 'absolute bg-gray-200',
            isHorizontal
              ? 'bottom-0 left-1/2 top-0 w-px -translate-x-1/2'
              : 'left-0 right-0 top-1/2 h-px -translate-y-1/2',
          ].join(' ')}
        />
        {showHandle ? (
          <span
            aria-hidden='true'
            className={[
              'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-gray-200 bg-white',
              isHorizontal ? 'h-9 w-1.5' : 'h-1.5 w-9',
            ].join(' ')}
          />
        ) : null}
      </div>

      <div
        id={secondPanelId}
        className='min-h-0 min-w-0 shrink-0 overflow-auto'
        style={{ flexBasis: `${100 - size}%` }}
      >
        {secondPanel}
      </div>
    </div>
  );
}

function Placeholder({ label }: { label: string }) {
  return (
    <div className='flex h-full min-h-32 items-center justify-center bg-white p-6 text-sm font-medium text-gray-500'>
      {label}
    </div>
  );
}

export type HorizontalSplitterProps = {
  leftPanel?: ReactNode;
  rightPanel?: ReactNode;
  defaultSize?: number;
  minSize?: number;
  ariaLabel?: string;
  showHandle?: boolean;
  className?: string;
};

export function HorizontalSplitter({
  leftPanel = <Placeholder label='Left panel' />,
  rightPanel = <Placeholder label='Right panel' />,
  defaultSize = 36,
  minSize = 20,
  ariaLabel = 'Resize left and right panels',
  showHandle = true,
  className = '',
}: HorizontalSplitterProps) {
  return (
    <ResizablePanels
      orientation='horizontal'
      firstPanel={leftPanel}
      secondPanel={rightPanel}
      defaultSize={defaultSize}
      minSize={minSize}
      ariaLabel={ariaLabel}
      showHandle={showHandle}
      className={className}
    />
  );
}

export type VerticalSplitterProps = {
  topPanel?: ReactNode;
  bottomPanel?: ReactNode;
  defaultSize?: number;
  minSize?: number;
  ariaLabel?: string;
  showHandle?: boolean;
  className?: string;
};

export function VerticalSplitter({
  topPanel = <Placeholder label='Top panel' />,
  bottomPanel = <Placeholder label='Bottom panel' />,
  defaultSize = 58,
  minSize = 20,
  ariaLabel = 'Resize top and bottom panels',
  showHandle = true,
  className = '',
}: VerticalSplitterProps) {
  return (
    <ResizablePanels
      orientation='vertical'
      firstPanel={topPanel}
      secondPanel={bottomPanel}
      defaultSize={defaultSize}
      minSize={minSize}
      ariaLabel={ariaLabel}
      showHandle={showHandle}
      className={className}
    />
  );
}

export type NestedSplitterProps = {
  sidebar?: ReactNode;
  topPanel?: ReactNode;
  bottomPanel?: ReactNode;
  defaultSidebarSize?: number;
  defaultTopSize?: number;
  minSize?: number;
  showHandle?: boolean;
  className?: string;
};

export function NestedSplitter({
  sidebar = <Placeholder label='Navigation' />,
  topPanel = <Placeholder label='Workspace' />,
  bottomPanel = <Placeholder label='Console' />,
  defaultSidebarSize = 28,
  defaultTopSize = 64,
  minSize = 20,
  showHandle = true,
  className = '',
}: NestedSplitterProps) {
  return (
    <HorizontalSplitter
      leftPanel={sidebar}
      rightPanel={
        <VerticalSplitter
          topPanel={topPanel}
          bottomPanel={bottomPanel}
          defaultSize={defaultTopSize}
          minSize={minSize}
          showHandle={showHandle}
          className='h-full w-full rounded-none border-0 shadow-none'
        />
      }
      defaultSize={defaultSidebarSize}
      minSize={minSize}
      showHandle={showHandle}
      className={className}
    />
  );
}

export default HorizontalSplitter;

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSS

Props

PropTypeDescription
sidebarReactNodeContent for the left sidebar
topPanelReactNodeContent for the upper workspace
bottomPanelReactNodeContent for the lower workspace
defaultSidebarSizenumberInitial sidebar width as a percentage
defaultTopSizenumberInitial upper panel height as a percentage
minSizenumberMinimum size for either panel as a percentage
showHandlebooleanShows or hides the small handle indicator on the divider
classNamestringStyles for the splitter container

More Splitters Components

Left panel
Right panel

A clean side-by-side React splitter for inboxes, editors, and dashboard layouts.

Top panel
Bottom panel

A stacked React splitter for charts, activity feeds, consoles, and detail panels.