---
title: Resizable
description: Resizable panel layout with draggable dividers and keyboard support.
sidebar:
  order: 12
search:
  tags: [panels, splitter, drag, resize]
---

# Resizable

> Resizable panel layout with draggable dividers and keyboard support.

## Import

```tsx
import { Resizable } from 'moraine'
```

## Slot Structure

Panels separated by interactive dividers with optional handle and intersection targets.

```text
root
├── panel (×n)
└── divider (between panels)
    ├── cross-target (optional, intersection start)
    ├── handle (optional)
    └── cross-target (optional, intersection end)
```

## Examples

### Basic Horizontal

Two panels with auto-inserted divider and root-level handle rendering.

```tsx
function BasicHorizontal() {
  function createPanel(title: string, description: string, tone: string) {
    return (
      <div class={`p-4 h-full ${tone}`}>
        <p class="text-sm text-foreground font-semibold">{title}</p>
        <p class="text-xs text-muted-foreground mt-1">{description}</p>
      </div>
    )
  }

  return (
    <div class="b-1 b-border border-border rounded-xl h-52 overflow-hidden">
      <Resizable
        handle
        panels={[
          {
            defaultSize: '40%',
            min: '20%',
            content: createPanel('Navigation', 'Left panel can shrink to 20%.', 'bg-muted'),
          },
          {
            defaultSize: '60%',
            min: '30%',
            content: createPanel(
              'Content',
              'Right panel keeps enough width for details.',
              'bg-background',
            ),
          },
        ]}
      />
    </div>
  )
}
```

### Controlled Sizes

Use panel.size + onResize to sync external state. The callback now returns pixel sizes.

```tsx
function ControlledSizes() {
  function formatPixelSizes(sizes: number[]): string {
    return sizes.map((size) => `${Math.round(size)}px`).join(' / ')
  }

  const [controlledPanels, setControlledPanels] = createStore([
    {
      size: 360,
      min: '20%' as const,
      content: createPanel(
        'Logs',
        'Drag or use arrow keys to rebalance with px callbacks.',
        'bg-muted',
      ),
    },
    {
      size: 640,
      min: '25%' as const,
      content: createPanel(
        'Preview',
        'The external store writes callback px values back into panel.size.',
        'bg-background',
      ),
    },
  ])

  const controlledSizes = createMemo(() =>
    controlledPanels.map((panel) => (typeof panel.size === 'number' ? panel.size : 0)),
  )

  function handleControlledResize(nextSizes: number[]): void {
    nextSizes.forEach((nextSize, index) => {
      if (Number.isFinite(nextSize)) {
        setControlledPanels(index, 'size', nextSize)
      }
    })
  }

  function createPanel(title: string, description: string, tone: string) {
    return (
      <div class={`p-4 h-full ${tone}`}>
        <p class="text-sm text-foreground font-semibold">{title}</p>
        <p class="text-xs text-muted-foreground mt-1">{description}</p>
      </div>
    )
  }

  return (
    <div class="space-y-3">
      <div class="b-1 b-border border-border rounded-xl h-48 overflow-hidden">
        <Resizable handle onResize={handleControlledResize} panels={controlledPanels} />
      </div>
      <p class="text-xs text-muted-foreground">
        Current sizes: {formatPixelSizes(controlledSizes())}
      </p>
    </div>
  )
}
```

### Vertical + Disable

The root disable prop keeps dividers visible while turning off drag and keyboard resizing.

```tsx
function VerticalDisable() {
  function createPanel(title: string, description: string, tone: string) {
    return (
      <div class={`p-4 h-full ${tone}`}>
        <p class="text-sm text-foreground font-semibold">{title}</p>
        <p class="text-xs text-muted-foreground mt-1">{description}</p>
      </div>
    )
  }

  return (
    <div class="gap-4 grid md:grid-cols-2">
      <div class="space-y-2">
        <p class="text-xs text-muted-foreground">
          <code>disable: false</code>
        </p>
        <div class="b-1 b-border border-border rounded-xl h-72 overflow-hidden">
          <Resizable
            orientation="vertical"
            handle
            classes={{ divider: 'bg-accent/80' }}
            panels={[
              {
                defaultSize: '33%',
                content: createPanel(
                  'Top',
                  'Interactive vertical divider between top and middle.',
                  'bg-muted',
                ),
              },
              {
                defaultSize: '34%',
                min: '30%',
                content: createPanel(
                  'Middle',
                  'All dividers remain present because handle settings live on the root now.',
                  'bg-background',
                ),
              },
              {
                defaultSize: '33%',
                content: createPanel('Bottom', 'Last panel in the vertical stack.', 'bg-muted'),
              },
            ]}
          />
        </div>
      </div>

      <div class="space-y-2">
        <p class="text-xs text-muted-foreground">
          <code>disable: true</code>
        </p>
        <div class="b-1 b-border border-border rounded-xl h-72 overflow-hidden">
          <Resizable
            disable
            orientation="vertical"
            handle
            classes={{ divider: 'bg-accent/80 opacity-80' }}
            panels={[
              {
                defaultSize: '33%',
                content: createPanel(
                  'Top',
                  'Divider stays visible but is not interactive.',
                  'bg-muted',
                ),
              },
              {
                defaultSize: '34%',
                content: createPanel(
                  'Middle',
                  'Keyboard and pointer resizing are both disabled.',
                  'bg-background',
                ),
              },
              {
                defaultSize: '33%',
                content: createPanel('Bottom', 'Useful for read-only layouts.', 'bg-muted'),
              },
            ]}
          />
        </div>
      </div>
    </div>
  )
}
```

### Nested Panels

Use the root intersection prop to control whether crossed dividers become draggable.

```tsx
function NestedPanels() {
  function createPanel(title: string, description: string, tone: string) {
    return (
      <div class={`p-4 h-full ${tone}`}>
        <p class="text-sm text-foreground font-semibold">{title}</p>
        <p class="text-xs text-muted-foreground mt-1">{description}</p>
      </div>
    )
  }

  return (
    <div class="gap-4 grid md:grid-cols-2">
      <div class="space-y-2">
        <p class="text-xs text-muted-foreground">
          <code>intersection: true</code>
        </p>
        <div class="b-1 b-border border-border rounded-xl h-72 overflow-hidden">
          <Resizable
            handle
            intersection
            panels={[
              {
                defaultSize: '32%',
                min: '20%',
                content: createPanel(
                  'Sidebar',
                  'Outer divider can intersect with the nested group.',
                  'bg-muted',
                ),
              },
              {
                defaultSize: '68%',
                min: '35%',
                content: (
                  <Resizable
                    orientation="vertical"
                    handle
                    intersection
                    panels={[
                      {
                        defaultSize: '50%',
                        min: '25%',
                        content: createPanel('Editor', 'Nested top panel.', 'bg-background'),
                      },
                      {
                        defaultSize: '50%',
                        min: '20%',
                        content: createPanel(
                          'Console',
                          'Nested bottom panel with cross drag enabled.',
                          'bg-muted/50',
                        ),
                      },
                    ]}
                  />
                ),
              },
              {
                defaultSize: '32%',
                min: '20%',
                content: createPanel(
                  'Sidebar',
                  'Outer divider can intersect with the nested group.',
                  'bg-muted',
                ),
              },
            ]}
          />
        </div>
      </div>

      <div class="space-y-2">
        <p class="text-xs text-muted-foreground">
          <code>intersection: false</code>
        </p>
        <div class="b-1 b-border border-border rounded-xl h-72 overflow-hidden">
          <Resizable
            handle
            intersection={false}
            panels={[
              {
                defaultSize: '68%',
                min: '35%',
                content: (
                  <Resizable
                    orientation="vertical"
                    handleRender={() => <Icon name="i-lucide:activity" />}
                    intersection={false}
                    panels={[
                      {
                        defaultSize: '50%',
                        min: '25%',
                        content: createPanel('Editor', 'Nested top panel.', 'bg-background'),
                      },
                      {
                        defaultSize: '50%',
                        min: '20%',
                        content: createPanel(
                          'Console',
                          'Nested bottom panel with cross drag disabled.',
                          'bg-muted/50',
                        ),
                      },
                    ]}
                  />
                ),
              },
              {
                defaultSize: '32%',
                min: '20%',
                content: createPanel(
                  'Inspector',
                  'Comparison panel for nested intersection behavior.',
                  'bg-muted',
                ),
              },
            ]}
          />
        </div>
      </div>
    </div>
  )
}
```

### Collapsible + Collapsible Min

Clicking handle toggles collapse/expand while dragging divider still resizes. The collapsibleMin rail remains visible in collapsed state.

```tsx
function CollapsibleCollapsibleMin() {
  function createPanel(title: string, description: string, tone: string) {
    return (
      <div class={`p-4 h-full ${tone}`}>
        <p class="text-sm text-foreground font-semibold">{title}</p>
        <p class="text-xs text-muted-foreground mt-1">{description}</p>
      </div>
    )
  }

  const [externalSizes, setExternalSizes] = createSignal<[number, number]>([320, 680])

  const externalPixelSizes = createMemo(() => formatPixelSizes(externalSizes()))

  function handleExternalResize(nextSizes: number[]): void {
    const sidebarSize = nextSizes[0]!
    const contentSize = nextSizes[1]!
    if (!Number.isFinite(sidebarSize) || !Number.isFinite(contentSize)) {
      return
    }

    setExternalSizes([sidebarSize, contentSize])
  }

  function formatPixelSizes(sizes: number[]): string {
    return sizes.map((size) => `${Math.round(size)}px`).join(' / ')
  }

  return (
    <div class="space-y-4">
      <div class="b-1 b-border border-border rounded-xl h-56 overflow-hidden">
        <Resizable
          handleAction="collapse"
          handleRender={(state) => (
            <Icon name={state.collapsed ? 'i-lucide:align-justify' : 'i-lucide:align-left'} />
          )}
          onResize={handleExternalResize}
          classes={{
            divider:
              'w-[6px] rounded-full bg-accent/45 transition-colors duration-200 hover:bg-accent/45 data-dragging:bg-primary/70',
          }}
          panels={[
            {
              size: externalSizes()[0],
              min: '16%',
              collapsible: true,
              collapsibleMin: '10%',
              content: createPanel(
                'Sidebar',
                'Click the handle to collapse/expand. Drag the divider to resize.',
                'bg-muted',
              ),
            },
            {
              size: externalSizes()[1],
              min: '24%',
              content: createPanel(
                'Editor',
                'Dragging still works and keeps controlled px sizes in sync.',
                'bg-background',
              ),
            },
          ]}
        />
      </div>

      <p class="text-xs text-muted-foreground">
        Try: click handle to toggle, drag divider to resize.
      </p>
      <p class="text-xs text-muted-foreground">Current sizes: {externalPixelSizes()}</p>
    </div>
  )
}
```

## API Reference

### Attributes

#### `root`

Layout container that owns resizable panels and handles.

##### Data Attributes

| Attribute | Type | Description |
| --- | --- | --- |
| data-orientation | string \| undefined | Stores the rendered orientation. |
| data-resizable-root | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |

#### `panel`

Content pane whose size is controlled by adjacent resize handles.

##### Data Attributes

| Attribute | Type | Description |
| --- | --- | --- |
| data-collapsed | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-expanded | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-orientation | string \| undefined | Stores the rendered orientation. |
| data-resizing | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-transitioning | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |

#### `divider`

Visual separator between adjacent panels.

##### Data Attributes

| Attribute | Type | Description |
| --- | --- | --- |
| data-active | string \| undefined | Present when the item is active. |
| data-cross | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-dragging | string \| undefined | Present while the related thumb or handle is being dragged. |
| data-orientation | string \| undefined | Stores the rendered orientation. |

##### ARIA Attributes

| Attribute | Type | Description |
| --- | --- | --- |
| aria-controls | boolean \| string \| undefined | References the controlled element while the related content is mounted. |
| aria-disabled | boolean \| string \| undefined | Indicates that the control is disabled. |
| aria-orientation | boolean \| string \| undefined | Communicates horizontal or vertical orientation. |
| aria-valuemax | boolean \| string \| undefined | Defines the maximum value for range-like controls. |
| aria-valuemin | boolean \| string \| undefined | Defines the minimum value for range-like controls. |
| aria-valuenow | boolean \| string \| undefined | Defines the current numeric value for range-like controls. |
| role | string | Defines the semantic role exposed to assistive technology. |

#### `handle`

Interactive target users drag or focus to resize panels.

#### `crossTarget`

Extra hit target used when nested handles meet across axes.

### Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | ClassValue | — | Class applied to the component root or trigger element. |
| classes | ResizableT.Classes \| undefined | — | — |
| disable | boolean \| undefined | false | Whether the resizable component is disabled. |
| handle | boolean \| undefined | true | Whether to render handles between panels. |
| handleAction | "resize" \| "collapse" \| undefined | resize | Handle interaction behavior.<br>- `resize`: handle area follows divider resize interactions.<br>- `collapse`: handle click toggles the nearest collapsible panel. |
| handleRender | ComponentOrElement<ResizableT.HandleRenderProps> \| undefined | — | Custom component rendered inside each handle. |
| id | string \| undefined | — | Unique identifier for the resizable root. |
| intersection | boolean \| undefined | false | Whether to use intersection-based handle sizing. |
| keyboardDelta | ResizableSize \| undefined | 10% | The amount to resize when using keyboard shortcuts. |
| onHandleKeyDown | ((context: { event: KeyboardEvent; handleIndex: number; sizes: number[]; }) => void) \| undefined | — | Callback when a key is pressed on a handle. |
| onResize | ((sizes: number[]) => void) \| undefined | — | Callback when any panel is resized. |
| onResizeEnd | ((sizes: number[]) => void) \| undefined | — | Callback when a resize operation ends. |
| onResizeStart | ((sizes: number[]) => void) \| undefined | — | Callback when a resize operation starts. |
| orientation | "horizontal" \| "vertical" \| undefined | — | — |
| panels | ResizableT.Item[] \| undefined | — | Array of panels to render. |
| ref | JSX.HTMLElementTags["div"] extends { ref?: infer Ref; } ? Ref : never \| undefined | — | — |
| style | JSX.CSSProperties \| undefined | — | — |
| styles | ResizableT.Styles \| undefined | — | — |

### Items

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| class | string \| undefined | — | Additional CSS classes for the panel. |
| collapsible | boolean \| undefined | false | Whether the panel is collapsible.<br>This prop is reactive; toggling `true/false` can be used as a simple collapse signal. |
| collapsibleMin | ResizableSize \| undefined | 0 | Size of the panel when collapsed.<br>Only works when `collapsible` is true.<br>- Use string as percent, like `20%`<br>- Use number as px, like `328` |
| content | JSX.Element | — | Content to render inside the panel. |
| defaultSize | ResizableSize \| undefined | — | Default size of the panel (uncontrolled).<br>- Use string as percent, like `20%`<br>- Use number as px, like `328` |
| max | ResizableSize \| undefined | 1 | Maximum size of the panel.<br>- Use string as percent, like `20%`<br>- Use number as px, like `328` |
| min | ResizableSize \| undefined | 0 | Minimum size of the panel.<br>- Use string as percent, like `20%`<br>- Use number as px, like `328` |
| onCollapse | ((size: number) => void) \| undefined | — | Callback when the panel is collapsed. |
| onExpand | ((size: number) => void) \| undefined | — | Callback when the panel is expanded. |
| onResize | ((size: number) => void) \| undefined | — | Callback when the panel is resized. |
| panelId | string \| undefined | — | Unique identifier for the panel. |
| resizable | boolean \| undefined | true | Whether the panel is resizable. |
| size | ResizableSize \| undefined | — | Current size of the panel (controlled).<br>- Use string as percent, like `20%`<br>- Use number as px, like `328` |
| style | JSX.CSSProperties \| undefined | — | Additional CSS styles for the panel. |

### ARIA

Accessibility attributes and roles emitted by the component markup.

| Attribute | Type | Description |
| --- | --- | --- |
| aria-controls | boolean \| string \| undefined | References the controlled element while the related content is mounted. |
| aria-disabled | boolean \| string \| undefined | Indicates that the control is disabled. |
| aria-orientation | boolean \| string \| undefined | Communicates horizontal or vertical orientation. |
| aria-valuemax | boolean \| string \| undefined | Defines the maximum value for range-like controls. |
| aria-valuemin | boolean \| string \| undefined | Defines the minimum value for range-like controls. |
| aria-valuenow | boolean \| string \| undefined | Defines the current numeric value for range-like controls. |
| role | string | Defines the semantic role exposed to assistive technology. |

### Data Attributes

State and slot attributes exposed for styling hooks and selectors.

| Attribute | Type | Description |
| --- | --- | --- |
| data-active | string \| undefined | Present when the item is active. |
| data-collapsed | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-cross | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-dragging | string \| undefined | Present while the related thumb or handle is being dragged. |
| data-expanded | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-orientation | string \| undefined | Stores the rendered orientation. |
| data-resizable-handle-end-target | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-resizable-handle-start-target | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-resizable-root | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-resizing | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
| data-slot | string | Identifies the rendered slot for styling hooks and selectors. |
| data-transitioning | string \| undefined | State or slot attribute exposed for styling hooks and selectors. |
