v1.0

Splitter

Preview

Basic

Loading…

Preview

Sizing

Loading…

Preview

Nested

Loading…

Preview

Code

ts
import Splitter from '@/components/layout/Splitter';

src/components/layout/Splitter.tsx

AI prompt

text
Build a two-pane resizable splitter component in React + TypeScript + Tailwind CSS.

## Look
- Root `flex h-full w-full`: `flex-row` for `horizontal` (left | right), `flex-col` for `vertical` (top / bottom).
- First pane `min-h-0 min-w-0 overflow-auto` sized by `flex-basis: <percent>%`; second pane `min-h-0 min-w-0 flex-1 overflow-auto`.
- Divider: a 1px line (`w-px` or `h-px`, `shrink-0`), `bg-slate-200 dark:bg-slate-700`, `hover:bg-indigo-400`, `focus-visible:bg-indigo-500` with no outline, `bg-indigo-500` while dragging, `cursor-col-resize` / `cursor-row-resize`, colour transition.
- A 1px line is nearly impossible to grab, so an invisible absolutely-positioned hit area straddles it, 6px each side (`inset-y-0 -left-1.5 -right-1.5`, or `inset-x-0 -top-1.5 -bottom-1.5`). The visible line stays 1px.

## Behaviour
- The size is a PERCENTAGE of the container, not pixels, so the split survives a window resize instead of drifting toward one edge. Always clamped to `[min, max]` so neither pane can be dragged out of existence.
- Pointer-down on the divider starts a drag. `pointermove` / `pointerup` listeners go on `window`, not the handle — the pointer routinely leaves a thin divider mid-drag. The percent is the pointer's position within the container's bounding rect.
- While dragging, set `user-select: none` and the matching resize cursor on `<body>` (a drag across text would otherwise select it), and restore both afterwards.
- Keyboard: the divider is focusable; ArrowLeft/ArrowRight (horizontal) or ArrowUp/ArrowDown (vertical) nudge by 2%, 10% with Shift, clamped.
- `onResize(percent)` fires on every drag move — for persisting the layout or reflowing a chart.
- Splitters nest in either direction.

## API
`first`, `second: ReactNode`; `direction?: 'horizontal' | 'vertical'` ('horizontal'); `initial?: number` (50, first pane %); `min?` (15); `max?` (85); `onResize?: (percent: number) => void`; `className`.

## Accessibility
Divider: `role="separator"`, `tabIndex={0}`, `aria-orientation` = the LINE's orientation (`vertical` for a left|right split), `aria-valuenow` (rounded percent), `aria-valuemin` / `aria-valuemax`.

## Demo
In an `h-72` rounded, bordered frame: a left pane at 35% ("Left pane — drag the divider, or focus it and use the arrow keys") and a right pane that is a vertical splitter at 60% ("Top right" / "Bottom right"). Each pane: a `text-xs font-semibold` title over an 11px slate-500 hint, `p-4`.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';

/**
 * Two resizable panes with a draggable divider.
 *
 * Sizes are held as a PERCENTAGE of the container, not pixels, so the split
 * survives a window resize instead of drifting toward one edge. `min` and `max`
 * clamp it so neither pane can be dragged out of existence.
 *
 * The drag listeners go on `window`, not the handle: the pointer routinely
 * leaves a 5px divider mid-drag, and a handle-scoped listener drops the gesture
 * the moment it does.
 */
export default function Splitter({
  first,
  second,
  direction = 'horizontal',
  initial = 50,
  min = 15,
  max = 85,
  className,
  onResize,
}: {
  first: React.ReactNode;
  second: React.ReactNode;
  /** `horizontal` splits left|right; `vertical` splits top/bottom. */
  direction?: 'horizontal' | 'vertical';
  /** Starting size of the first pane, as a percentage. */
  initial?: number;
  min?: number;
  max?: number;
  className?: string;
  onResize?: (percent: number) => void;
}) {
  const [percent, setPercent] = useState(initial);
  const [dragging, setDragging] = useState(false);
  const ref = useRef<HTMLDivElement>(null);
  const isH = direction === 'horizontal';

  const move = useCallback(
    (e: PointerEvent) => {
      const el = ref.current;
      if (!el) return;
      const r = el.getBoundingClientRect();
      const raw = isH ? ((e.clientX - r.left) / r.width) * 100 : ((e.clientY - r.top) / r.height) * 100;
      const next = Math.min(Math.max(raw, min), max);
      setPercent(next);
      onResize?.(next);
    },
    [isH, min, max, onResize],
  );

  useEffect(() => {
    if (!dragging) return;
    const stop = () => setDragging(false);
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', stop);
    // A drag that crosses text would otherwise select it, which looks like a bug.
    const prev = document.body.style.userSelect;
    document.body.style.userSelect = 'none';
    document.body.style.cursor = isH ? 'col-resize' : 'row-resize';
    return () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', stop);
      document.body.style.userSelect = prev;
      document.body.style.cursor = '';
    };
  }, [dragging, move, isH]);

  const nudge = (e: React.KeyboardEvent) => {
    const step = e.shiftKey ? 10 : 2;
    const back = isH ? 'ArrowLeft' : 'ArrowUp';
    const fwd = isH ? 'ArrowRight' : 'ArrowDown';
    if (e.key !== back && e.key !== fwd) return;
    e.preventDefault();
    setPercent((p) => Math.min(Math.max(p + (e.key === fwd ? step : -step), min), max));
  };

  return (
    <div ref={ref} className={cn('flex h-full w-full', isH ? 'flex-row' : 'flex-col', className)}>
      <div className="min-h-0 min-w-0 overflow-auto" style={{ flexBasis: `${percent}%` }}>
        {first}
      </div>

      <div
        role="separator"
        aria-orientation={isH ? 'vertical' : 'horizontal'}
        aria-valuenow={Math.round(percent)}
        aria-valuemin={min}
        aria-valuemax={max}
        tabIndex={0}
        onPointerDown={() => setDragging(true)}
        onKeyDown={nudge}
        className={cn(
          'group relative shrink-0 bg-slate-200 dark:bg-slate-700 transition-colors',
          'hover:bg-indigo-400 focus-visible:bg-indigo-500 focus-visible:outline-none',
          dragging && 'bg-indigo-500',
          isH ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize',
        )}
      >
        {/* A 1px divider is nearly impossible to grab, so an invisible padded
            hit area straddles it. The visible line stays 1px. */}
        <span
          className={cn(
            'absolute',
            isH ? 'inset-y-0 -left-1.5 -right-1.5' : 'inset-x-0 -top-1.5 -bottom-1.5',
          )}
        />
      </div>

      <div className="min-h-0 min-w-0 flex-1 overflow-auto">{second}</div>
    </div>
  );
}

Props

PropTypeDefaultDescription
first*React.ReactNode—
second*React.ReactNode—
direction'horizontal' | 'vertical''horizontal'`horizontal` splits left|right; `vertical` splits top/bottom.
initialnumber50Starting size of the first pane, as a percentage.
minnumber15
maxnumber85
classNamestring—
onResize(percent: number) => void—