React split panes done right: resizable, accessible, and fast

Build robust React split-pane layouts: DIY implementation, libraries, accessibility, performance, and persistence, with practical code examples.

ASOasis
8 min read
React split panes done right: resizable, accessible, and fast

Image used for representation purposes only.

Overview

Split panes let users resize sections of an interface to fit their task: larger editor, smaller sidebar; wide results, narrow filters; or stacked logs under a console. In React, a good split-pane layout is smooth, accessible, and resilient across devices and window sizes. This guide shows how to build one from scratch, when to reach for a library, and how to handle details like keyboard resizing, persistence, and nested panes.

When to use a split pane

Use a resizable split when:

  • Two or three regions must remain visible simultaneously.
  • The relative importance of regions changes frequently (code vs. preview, list vs. details).
  • Users benefit from quick, continuous adjustments instead of presets.

Avoid a split if a single region should dominate most of the time, or when toggling layouts is simpler and clearer.

Design principles

  • Predictable: The divider should never create overlapping content or push controls off-screen.
  • Constrained: Enforce sensible min and max sizes for each pane.
  • Accessible: The separator must be reachable by keyboard and announced by assistive tech.
  • Performant: Resize at 60 fps under continuous drag; avoid expensive reflows.
  • Persistent: Remember user choices between visits.

Building a split pane without a library

The simplest robust approach uses CSS Grid for layout and Pointer Events for drag handling. Grid gives you clean columns or rows; JS adjusts a single CSS custom property to set the pane size.

Markup and styles

import React from 'react';

export function SplitPane({
  initial = 360,        // starting left pane width in px
  minLeft = 200,        // left pane cannot be smaller
  minRight = 260,       // right pane cannot be smaller
  gutter = 8,           // separator thickness in px
  onChange,             // optional callback(sizePx)
  children,             // [left, right]
}) {
  const containerRef = React.useRef(null);
  const [leftPx, setLeftPx] = React.useState(initial);
  const frameRef = React.useRef(0);

  // Keep leftPx within container bounds on mount and on resize
  React.useLayoutEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const clamp = () => {
      const total = el.clientWidth;
      const maxLeft = Math.max(minLeft, total - gutter - minRight);
      setLeftPx(v => Math.min(Math.max(v, minLeft), maxLeft));
    };

    const ro = new ResizeObserver(clamp);
    ro.observe(el);
    clamp();
    return () => ro.disconnect();
  }, [minLeft, minRight, gutter]);

  const startRef = React.useRef({ startX: 0, startLeft: 0 });

  const onPointerDown = (e: React.PointerEvent) => {
    const el = containerRef.current;
    if (!el) return;
    (e.target as Element).setPointerCapture(e.pointerId);
    startRef.current = { startX: e.clientX, startLeft: leftPx };

    const onMove = (clientX: number) => {
      const total = el.clientWidth;
      const maxLeft = Math.max(minLeft, total - gutter - minRight);
      const next = startRef.current.startLeft + (clientX - startRef.current.startX);
      const clamped = Math.min(Math.max(next, minLeft), maxLeft);
      // rAF to avoid layout thrash
      cancelAnimationFrame(frameRef.current);
      frameRef.current = requestAnimationFrame(() => {
        setLeftPx(clamped);
        onChange?.(clamped);
      });
    };

    const onPointerMove = (ev: PointerEvent) => onMove(ev.clientX);
    const onPointerUp = (ev: PointerEvent) => {
      (e.target as Element).releasePointerCapture(e.pointerId);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
    };

    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp, { once: true });
  };

  // Keyboard support on the separator
  const onKeyDown = (e: React.KeyboardEvent) => {
    const step = e.shiftKey ? 32 : 8; // larger jump with Shift
    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
      e.preventDefault();
      const dir = e.key === 'ArrowLeft' ? -1 : 1;
      const el = containerRef.current!;
      const total = el.clientWidth;
      const maxLeft = Math.max(minLeft, total - gutter - minRight);
      const next = Math.min(Math.max(leftPx + dir * step, minLeft), maxLeft);
      setLeftPx(next);
      onChange?.(next);
    }
  };

  return (
    <div
      ref={containerRef}
      className='split-row'
      style={{ ['--left' as any]: `${leftPx}px`, ['--gutter' as any]: `${gutter}px` }}
    >
      <div className='pane pane-left'>{Array.isArray(children) ? children[0] : children}</div>
      <div
        className='separator'
        role='separator'
        aria-orientation='vertical'
        aria-label='Resize'
        aria-valuemin={minLeft}
        aria-valuemax={(() => {
          const el = containerRef.current; if (!el) return undefined;
          return Math.max(minLeft, el.clientWidth - gutter - minRight);
        })()}
        aria-valuenow={leftPx}
        tabIndex={0}
        onPointerDown={onPointerDown}
        onKeyDown={onKeyDown}
      />
      <div className='pane pane-right'>{Array.isArray(children) ? children[1] : null}</div>
    </div>
  );
}
.split-row {
  /* 3 columns: left, gutter, right */
  display: grid;
  grid-template-columns: var(--left, 360px) var(--gutter, 8px) 1fr;
  min-height: 0; /* allow children to shrink */
}
.pane {
  min-width: 0; /* critical: text can shrink without overflow */
  overflow: auto;
}
.separator {
  cursor: col-resize;
  touch-action: none; /* allow horizontal drag on touch */
  background: transparent; /* invisible hit area */
  position: relative;
}
.separator::after {
  /* visible handle */
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  width: 2px;
  height: 100%;
  transform: translateX(-50%);
  background: color-mix(in oklab, CanvasText 20%, transparent);
}
.separator:focus-visible::after {
  background: Highlight;
}

Notes:

  • The layout avoids inline width updates on child panes, changing only a single CSS custom property.
  • touch-action: none on the separator enables predictable drag on touch devices.
  • min-width: 0 prevents grid children from overflowing when space is tight.

Persisting a user-chosen size

function usePersistedNumber(key: string, initial: number) {
  const [value, setValue] = React.useState(() => {
    const raw = localStorage.getItem(key);
    return raw == null ? initial : Number(raw);
  });
  React.useEffect(() => {
    localStorage.setItem(key, String(value));
  }, [key, value]);
  return [value, setValue] as const;
}

export function SidebarLayout() {
  const [left, setLeft] = usePersistedNumber('sidebar.width', 320);
  return (
    <SplitPane initial={left} onChange={setLeft} minLeft={220} minRight={300}>
      <Sidebar />
      <MainArea />
    </SplitPane>
  );
}

Vertical orientation

For a top and bottom split, swap grid-template-columns with grid-template-rows, track height instead of width, and use row-resize cursor with aria-orientation set to horizontal.

.split-col {
  display: grid;
  grid-template-rows: var(--top, 280px) var(--gutter, 8px) 1fr;
}
.separator.y { cursor: row-resize; }

Accessibility details

  • role=separator informs assistive tech that the element resizes content.
  • aria-orientation: vertical for left/right, horizontal for top/bottom.
  • aria-valuemin, aria-valuemax, aria-valuenow communicate the current pixel size. Update as the container resizes.
  • Keyboard: support ArrowLeft and ArrowRight (or ArrowUp and ArrowDown) with small and large steps (Shift). Ensure the separator is focusable via tabIndex.
  • Focus styles: provide a clear visible focus indication.

Performance and UX tips

  • Throttle with requestAnimationFrame, not setState on every raw pointermove.
  • Prefer CSS transforms or custom properties over repeatedly mutating style.width on panes.
  • Disable expensive work during drag; for example, defer chart reflows until pointerup.
  • Make the hit target at least 8 px wide; visually you can render a 2 px hairline inside it.
  • Use ResizeObserver to keep constraints valid when the container changes size.

Using a library

Libraries give you polished behavior, nested panes, and edge-case handling with less code. Two popular choices are shown below.

react-split-pane example

import SplitPane from 'react-split-pane';

export function App() {
  const [size, setSize] = React.useState(() => {
    const raw = localStorage.getItem('left');
    return raw ? Number(raw) : 340;
  });
  return (
    <SplitPane
      split='vertical'          // or 'horizontal'
      minSize={220}
      maxSize={-300}            // negative means from the far edge
      size={size}               // controlled
      onChange={(v: number) => { setSize(v); localStorage.setItem('left', String(v)); }}
      allowResize
      paneStyle={{ overflow: 'auto' }}
    >
      <Sidebar />
      <Main />
    </SplitPane>
  );
}

Highlights:

  • Negative maxSize caps the opposite pane, which is handy for responsive UIs.
  • Controlled mode makes persistence straightforward.

react-split (Split.js wrapper)

import Split from 'react-split';

export function TwoPane() {
  const [sizes, setSizes] = React.useState(() => {
    const raw = localStorage.getItem('sizes');
    return raw ? JSON.parse(raw) : [35, 65]; // percentages
  });

  return (
    <Split
      className='split-row'
      sizes={sizes}
      minSize={[220, 280]}
      gutterSize={8}
      onDragEnd={(next) => { setSizes(next); localStorage.setItem('sizes', JSON.stringify(next)); }}
      direction='horizontal'
      snapOffset={12}
    >
      <div className='pane'>A</div>
      <div className='pane'>B</div>
    </Split>
  );
}

This approach uses percentages by default, which adapt well to window resizes.

Advanced patterns

Nested panes

Compose panes for tri-pane layouts. Each SplitPane manages its own size and constraints; ensure inner panes have min-width: 0 to avoid overflow.

<SplitPane initial={280} minLeft={220} minRight={260}>
  <Navigation />
  <SplitPane initial={520} minLeft={360} minRight={260}>
    <Editor />
    <Preview />
  </SplitPane>
</SplitPane>

Collapsible and snap points

Add a double-click to toggle a collapsed state, and snap widths near useful breakpoints.

const snaps = [240, 320, 480];
const snapTo = (px: number, tolerance = 12) => {
  for (const s of snaps) if (Math.abs(px - s) <= tolerance) return s;
  return px;
};

// In onMove/onKeyDown, after clamping:
const snapped = snapTo(clamped);
setLeftPx(snapped);

Controlled vs. uncontrolled

  • Controlled: parent owns size; easy persistence; more re-renders but predictable.
  • Uncontrolled: component stores internal state; simpler wiring; lift state only when needed.

Responsive behavior

  • Switch orientation at breakpoints: horizontal above 1024 px, vertical below.
  • Replace split with a tabbed layout on small screens.
  • Use negative max constraints (in libraries) or recompute clamps on resize (DIY) so the reading pane never shrinks below its minimum.

Server rendering and hydration

If you SSR, avoid reading container sizes during the first render. Use layout effects to measure after hydration, and render a reasonable default to prevent layout shift.

Virtualized content

Heavy lists inside panes should use virtualization. Keep the pane container with overflow: auto to ensure the virtualizer controls the scroll region.

Testing checklist

  • Mouse, touch, and trackpad drag behave smoothly.
  • Keyboard resizing works and is announced to screen readers.
  • Min and max constraints hold during window resizes.
  • Persistence restores correctly across reloads and route changes.
  • Nested panes do not overflow; content remains focusable.
  • Performance under drag stays smooth on low-power devices.

Common pitfalls

  • Missing min-width: 0 on grid children causes unexpected overflow and broken layouts.
  • Using inline width on panes instead of a grid variable complicates constraints and nesting.
  • Forgetting touch-action: none prevents reliable dragging on touch devices.
  • Recomputing expensive layouts on every pointermove leads to jank; throttle with rAF.
  • Not exposing role=separator and keyboard handlers excludes keyboard-only users.

Putting it all together

Start with CSS Grid and a single custom property to represent the adjustable size. Add a separator with role=separator, pointer and keyboard handlers, and a ResizeObserver to keep constraints accurate. If you need percentages, snap points, nested layouts, or vertical splits with little ceremony, a small library can save time. Whichever route you take, lock down min and max sizes, keep the UI smooth during drag, and persist what users choose.

With these patterns in place, you can ship split panes that feel native, respect accessibility, and stay robust as your app grows.

Related Posts