# Splitter

Panels side by side or stacked, with handles between them that people drag, or move with the keyboard, to resize them.

- **Import:** `import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"`
- **Also install:** `react-resizable-panels`
- **APG Window splitter:** <https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/>
- **Page:** <https://ui.booleanpress.com/components/splitter> · @booleanpress/ui 0.2.0

## Usage

`Splitter` is [react-resizable-panels](https://react-resizable-panels.vercel.app) 4 with the package's classes: shadcn's Resizable, renamed. It needs the `react-resizable-panels` peer package, which only products that import `@booleanpress/ui/splitter` install. Put a `SplitterHandle` between every two `SplitterPanel`s.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export function Inbox() {
  return (
    <Splitter className="min-h-80">
      <SplitterPanel defaultSize="30%" minSize="20%">Folders</SplitterPanel>
      <SplitterHandle aria-label="Resize folders" />
      <SplitterPanel>Messages</SplitterPanel>
    </Splitter>
  )
}
```

Sizes are strings in percent (`"30%"`, or `"30"`) or other CSS units (`"240px"`, `"20rem"`); a bare number is pixels. `defaultSize` sets where a panel starts, `minSize` and `maxSize` limit it, and `collapsible` with `collapsedSize` lets it fold away when it is dragged past its minimum. `orientation="vertical"` stacks the panels. The splitter fills its container's width; give it a height, or a `min-h-*`, or put it in a box that has one. A splitter inside a panel drops its own frame, so nested splitters read as one. To keep the sizes between visits, pass `onLayoutChanged` and `defaultLayout` on `Splitter`, or use the library's `useDefaultLayout` hook; `panelRef` on a panel gives `collapse()`, `expand()` and `resize()`.

## Examples

### Basic

Two panels sharing the width equally, with a handle between them.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterBasic() {
  return (
    <Splitter className="mx-auto min-h-60 max-w-lg">
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Mailers</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize mailers" />
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Settings</span>
      </SplitterPanel>
    </Splitter>
  )
}
```

### Vertical

`orientation="vertical"` stacks the panels; the handle moves up and down.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterVertical() {
  return (
    <Splitter orientation="vertical" className="mx-auto min-h-60 max-w-lg">
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Message</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize message" />
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Delivery log</span>
      </SplitterPanel>
    </Splitter>
  )
}
```

### Sizes

`defaultSize` starts the panels at 25 % and 75 %.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterSizes() {
  return (
    <Splitter className="mx-auto min-h-60 max-w-lg">
      <SplitterPanel defaultSize="25%" className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Folders</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize folders" />
      <SplitterPanel defaultSize="75%" className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Messages</span>
      </SplitterPanel>
    </Splitter>
  )
}
```

### Min and max

`minSize` and `maxSize` stop a panel at 30 % and 70 %; below, a 120 px minimum beside two 20 % ones.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

function PanelLabel({ children }: { children: string }) {
  return <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">{children}</span>
}

export default function SplitterMinMax() {
  return (
    <div className="mx-auto flex w-full max-w-lg flex-col gap-8">
      <Splitter className="min-h-32">
        <SplitterPanel minSize="30%" maxSize="70%" className="flex items-center justify-center">
          <PanelLabel>30–70 %</PanelLabel>
        </SplitterPanel>
        <SplitterHandle aria-label="Resize the first panel" />
        <SplitterPanel className="flex items-center justify-center">
          <PanelLabel>The rest</PanelLabel>
        </SplitterPanel>
      </Splitter>
      <Splitter className="min-h-32">
        <SplitterPanel minSize={120} className="flex items-center justify-center">
          <PanelLabel>Min 120 px</PanelLabel>
        </SplitterPanel>
        <SplitterHandle aria-label="Resize the first panel" />
        <SplitterPanel minSize="20%" className="flex items-center justify-center">
          <PanelLabel>Min 20 %</PanelLabel>
        </SplitterPanel>
        <SplitterHandle aria-label="Resize the second panel" />
        <SplitterPanel minSize="20%" className="flex items-center justify-center">
          <PanelLabel>Min 20 %</PanelLabel>
        </SplitterPanel>
      </Splitter>
    </div>
  )
}
```

### Collapsible

`collapsible` folds the filters away when they are dragged under 25 %, or when the handle has focus and Enter is pressed.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterCollapsible() {
  return (
    <Splitter className="mx-auto min-h-60 max-w-lg">
      <SplitterPanel
        collapsible
        collapsedSize="0%"
        minSize="25%"
        defaultSize="35%"
        className="flex items-center justify-center"
      >
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Filters</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize filters. Enter collapses them." withHandle />
      <SplitterPanel className="flex items-center justify-center p-4 text-center">
        <p className="text-sm/normal text-muted-foreground">
          Drag the handle past the minimum to collapse the filters, or focus it and press Enter.
        </p>
      </SplitterPanel>
    </Splitter>
  )
}
```

### Nested

A vertical splitter in a panel, and a horizontal one inside that; only the outer one has a frame.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

function PanelLabel({ children }: { children: string }) {
  return <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">{children}</span>
}

export default function SplitterNested() {
  return (
    <Splitter className="mx-auto min-h-80 max-w-lg">
      <SplitterPanel defaultSize="25%" className="flex items-center justify-center">
        <PanelLabel>Folders</PanelLabel>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize folders" />
      <SplitterPanel defaultSize="75%">
        <Splitter orientation="vertical">
          <SplitterPanel defaultSize="50%" className="flex items-center justify-center">
            <PanelLabel>Messages</PanelLabel>
          </SplitterPanel>
          <SplitterHandle aria-label="Resize messages" />
          <SplitterPanel defaultSize="50%">
            <Splitter>
              <SplitterPanel defaultSize="25%" className="flex items-center justify-center">
                <PanelLabel>Tags</PanelLabel>
              </SplitterPanel>
              <SplitterHandle aria-label="Resize tags" />
              <SplitterPanel defaultSize="75%" className="flex items-center justify-center">
                <PanelLabel>Preview</PanelLabel>
              </SplitterPanel>
            </Splitter>
          </SplitterPanel>
        </Splitter>
      </SplitterPanel>
    </Splitter>
  )
}
```

### With handle grip

`withHandle` draws a grip on the bar.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterWithHandle() {
  return (
    <Splitter className="mx-auto min-h-60 max-w-lg">
      <SplitterPanel defaultSize="40%" className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Contacts</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize contacts" withHandle />
      <SplitterPanel defaultSize="60%" className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Contact details</span>
      </SplitterPanel>
    </Splitter>
  )
}
```

### Disabled

`disabled` fixes the sizes: the handles fade, leave the tab order and ignore the pointer.

```tsx
import { Splitter, SplitterHandle, SplitterPanel } from "@booleanpress/ui/splitter"

export default function SplitterDisabled() {
  return (
    <Splitter disabled className="mx-auto min-h-32 max-w-lg">
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Sent</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize sent" />
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Failed</span>
      </SplitterPanel>
      <SplitterHandle aria-label="Resize failed" />
      <SplitterPanel className="flex items-center justify-center">
        <span className="rounded-md bg-muted px-2 py-1 text-sm/normal font-semibold">Queued</span>
      </SplitterPanel>
    </Splitter>
  )
}
```

## Accessibility

**Semantics.** Each handle is a `div` with `role="separator"`, `aria-orientation` (`vertical` between side-by-side panels), and `aria-valuenow`, `aria-valuemin` and `aria-valuemax`: the size of the panel before it, in percent. `aria-controls` points at that panel.

**Labels.** Name every handle with `aria-label`, or `aria-labelledby` pointing at the heading of the panel it resizes: "Resize folders". The panels are plain containers; give them headings or landmarks of your own when they hold whole areas of the screen.

**Focus.** Each handle is a tab stop; the 24 px part in its middle shows the focus outline. A disabled splitter's handles leave the tab order.

**Known limits.**

- react-resizable-panels has no right-to-left mode: in a right-to-left row its pointer drag and arrow keys would move the handle the wrong way. In a right-to-left page a horizontal splitter therefore keeps its panels in source order from left to right, with right-to-left content inside them. A vertical splitter is unaffected.
- Each step of an arrow key is 5 % of the splitter. The step is the library's and cannot be changed.
- The bar is 1 px; the library makes its grab area at least 10 px wide for a mouse and 20 px for touch (`resizeTargetMinimumSize`).
- Sizes in percent may shift slightly as a server-rendered page hydrates; pixel sizes do not.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ← | Between side-by-side panels, moves the handle left: the panel before it shrinks by 5 %. |
| → | Between side-by-side panels, moves the handle right: the panel before it grows by 5 %. |
| ↑ | Between stacked panels, moves the handle up by 5 %. |
| ↓ | Between stacked panels, moves the handle down by 5 %. |
| Home | Gives the panel before the handle its smallest size. |
| End | Gives the panel before the handle its largest size. |
| Enter | Collapses the panel before the handle when it is `collapsible`, or restores it when it is collapsed. |
| F6 | Moves focus to the next handle in the splitter; with Shift, to the previous one. |

## API

### Splitter

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultLayout` | `Layout` |  | Default layout for the Group. ℹ️ This value allows layouts to be remembered between page reloads. ⚠️ Slight layout shift may occur when server-rendering panels with percentage-based default sizes. Refer to the documentation for suggestions on how to minimize the impact of this. |
| `disableCursor` | `boolean` |  | This library sets custom mouse cursor styles to indicate drag state. Use this prop to disable that behavior for Panels and Separators in this group. |
| `disabled` | `boolean` |  | Disable resize functionality. |
| `elementRef` | `Ref<HTMLDivElement \| null>` |  | Ref attached to the root `HTMLDivElement`. |
| `groupRef` | `Ref<GroupImperativeHandle \| null>` |  | Exposes the following imperative API: - `getLayout(): Layout` - `setLayout(layout: Layout): void` ℹ️ The `useGroupRef` and `useGroupCallbackRef` hooks are exported for convenience use in TypeScript projects. |
| `onLayoutChange` | `((layout: Layout) => void)` |  | Called when the Group's layout is changing. ⚠️ For layout changes caused by pointer events, this method is called each time the pointer is moved. For most cases, it is recommended to use the `onLayoutChanged` callback instead. |
| `onLayoutChanged` | `((layout: Layout, meta: LayoutChangedMeta<Layout>) => void)` |  | Called after the Group's layout has  been changed. ℹ️ For layout changes caused by pointer events, this method is not called until the pointer has been released. This method is recommended when saving layouts to some storage api. ℹ️ The second argument contains meta information about the layout change. The `isUserInteraction` attribute signals whether the resize was caused by direct user input. It is true for resizes caused by pointer or keyboard input and false for other triggers (e.g. imperative API calls, initial mount, etc.) The `requestedLayout` attribute is the layout before constraints were applied for the current Group size; prefer it when persisting layouts. |
| `orientation` | `"horizontal" \| "vertical"` | `horizontal` | Specifies the resizable orientation ("horizontal" or "vertical"); defaults to "horizontal" |
| `resizePreviewMode` | `"separator" \| "panel"` |  | Controls whether pointer dragging updates `Panel`s sizes immediately, or renders overlay separator previews until the pointer is released. Defaults to `"panel"` (immediate resizing); `"separator"` defers resizing until release. Customize previews using the `SeparatorOverlay` component. |
| `resizeTargetMinimumSize` | `{ coarse: number; fine: number; }` |  | Minimum size of the resizable hit target area (either `Separator` or `Panel` edge) This threshold ensures are large enough to avoid mis-clicks. - Coarse inputs (typically a finger on a touchscreen) have reduced accuracy; to ensure accessibility and ease of use, hit targets should be larger to prevent mis-clicks. - Fine inputs (typically a mouse) can be smaller ℹ️ [Apple interface guidelines](https://developer.apple.com/design/human-interface-guidelines/accessibility) suggest `20pt` (`27px`) on desktops and `28pt` (`37px`) for touch devices In practice this seems to be much larger than many of their own applications use though. |

### SplitterHandle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `disabled` | `boolean` |  | When disabled, the separator cannot be used to resize its neighboring panels. ℹ️ The panels may still be resized indirectly (while other panels are being resized). To prevent a panel from being resized at all, it needs to also be disabled. |
| `disableDoubleClick` | `boolean` |  | When true, double-clicking this `Separator` will not reset its `Panel` to its default size. |
| `elementRef` | `Ref<HTMLDivElement>` |  | Ref attached to the root `HTMLDivElement`. |
| `preview` | `ReactNode` |  | Overrides the `Group` default preview for this `Separator` when `resizePreviewMode` is "separator". |
| `withHandle` | `boolean` |  | Show a grip on the bar. |

### SplitterPanel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `collapsedSize` | `string \| number` |  | Panel size when collapsed; defaults to 0%. |
| `collapsedThreshold` | `string \| number` |  | Distance a collapsible panel must be resized past its `minSize` to collapse, or past its `collapsedSize` to expand. Defaults to half the distance between `collapsedSize` and `minSize`. For example if a panel declares `collapsedSize="5%"`, `collapsedThreshold="5%"`, and `minSize="25%"`, it will collapse when resized below 20% and expands when resized above 10%. ℹ️ Interpretation rules: - Numbers are interpreted as pixels (e.g. `minSize={200}` is 200 pixels) - Strings without explicit units are interpreted as percentage (e.g. `minSize="50"` is 50 percent) - Use explicit units (e.g. "px", "%", "em", "rem", "vh", or "vw") to change interpretation |
| `collapsible` | `boolean` |  | This panel can be collapsed. ℹ️ A collapsible panel will collapse when it's size is less than of the specified `minSize` |
| `defaultSize` | `string \| number` |  | Default size of Panel within its parent group; default is auto-assigned based on the total number of Panels. ℹ️ Interpretation rules: - Numbers are interpreted as pixels (e.g. `defaultSize={200}` is 200 pixels) - Strings without explicit units are interpreted as percentage (e.g. `defaultSize="50"` is 50 percent) - Use explicit units (e.g. "px", "%", "em", "rem", "vh", or "vw") to change interpretation ⚠️ Percentage based sizes may cause slight layout shift when server-rendering. For more information see the documentation. |
| `disabled` | `boolean` |  | When disabled, a panel cannot be resized either directly or indirectly (by resizing another panel). |
| `elementRef` | `Ref<HTMLDivElement \| null>` |  | Ref attached to the root `HTMLDivElement`. |
| `groupResizeBehavior` | `"preserve-relative-size" \| "preserve-pixel-size"` |  | How should this Panel behave if the parent Group is resized? Defaults to `preserve-relative-size`. - `preserve-relative-size`: Retain the current relative size (as a percentage of the Group) - `preserve-pixel-size`: Retain its current size (in pixels) ℹ️ Panel min/max size constraints may impact this behavior. ⚠️ A Group must contain at least one Panel with `preserve-relative-size` resize behavior. |
| `maxSize` | `string \| number` |  | Maximum size of Panel within its parent group; defaults to `"100%"`. ℹ️ Interpretation rules: - Numbers are interpreted as pixels (e.g. `maxSize={200}` is 200 pixels) - Strings without explicit units are interpreted as percentage (e.g. `maxSize="50"` is 50 percent) - Use explicit units (e.g. "px", "%", "em", "rem", "vh", or "vw") to change interpretation |
| `minSize` | `string \| number` |  | Minimum size of Panel within its parent group; defaults to 0%. ℹ️ Interpretation rules: - Numbers are interpreted as pixels (e.g. `minSize={200}` is 200 pixels) - Strings without explicit units are interpreted as percentage (e.g. `minSize="50"` is 50 percent) - Use explicit units (e.g. "px", "%", "em", "rem", "vh", or "vw") to change interpretation |
| `onResize` | `((panelSize: PanelSize, id: string \| number, prevPanelSize: PanelSize) => void) \| undefined` |  | Called when panel sizes change. |
| `panelRef` | `Ref<PanelImperativeHandle \| null>` |  | Exposes the following imperative API: - `collapse(): void` - `expand(): void` - `getSize(): number` - `isCollapsed(): boolean` - `resize(size: number): void` ℹ️ The `usePanelRef` and `usePanelCallbackRef` hooks are exported for convenience use in TypeScript projects. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="splitter"` (Splitter), `data-slot="splitter-handle"` (SplitterHandle), `data-slot="splitter-panel"` (SplitterPanel), and `data-orientation`.

## Theming

The frame is `--card` with the `--border` edge; the bar is `--border`. The grip is `--card` with the `--border` edge and a `--muted-foreground` icon. Focus is the `--ring` outline around the 24 px handle.

| Token | Used for |
| --- | --- |
| `--border` | background |
| `--card` | background |
| `--card-foreground` | text |
| `--muted-foreground` | text |
| `--ring` | outline |
