icon

Morainev0.5.0

Resizable
elementsresizable

Resizable

Resizable panel layout with draggable dividers and keyboard support.

Import#

import { Resizable } from 'moraine'

Slot Structure#

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

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.

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.

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.

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.

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.

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#

Slotroot2 attributes
Layout container that owns resizable panels and handles.

Data Attributes

2
Data AttributeDescription
data-orientation
Stores the rendered orientation.
data-resizable-root
State or slot attribute exposed for styling hooks and selectors.

Props#

PropTypeDefaultDescription
classClassValue
Class applied to the component root or trigger element.
classesResizableT.Classes | undefined
disableboolean | undefinedfalse
Whether the resizable component is disabled.
handleboolean | undefinedtrue
Whether to render handles between panels.
handleAction"resize" | "collapse" | undefinedresize
Handle interaction behavior. - `resize`: handle area follows divider resize interactions. - `collapse`: handle click toggles the nearest collapsible panel.
handleRenderComponentOrElement<ResizableT.HandleRenderProps> | undefined
Custom component rendered inside each handle.
idstring | undefined
Unique identifier for the resizable root.
intersectionboolean | undefinedfalse
Whether to use intersection-based handle sizing.
keyboardDeltaResizableSize | undefined10%
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
panelsResizableT.Item[] | undefined
Array of panels to render.
refJSX.HTMLElementTags["div"] extends { ref?: infer Ref; } ? Ref : never | undefined
styleJSX.CSSProperties | undefined
stylesResizableT.Styles | undefined

Items#

PropTypeDefaultDescription
classstring | undefined
Additional CSS classes for the panel.
collapsibleboolean | undefinedfalse
Whether the panel is collapsible. This prop is reactive; toggling `true/false` can be used as a simple collapse signal.
collapsibleMinResizableSize | undefined0
Size of the panel when collapsed. Only works when `collapsible` is true. - Use string as percent, like `20%` - Use number as px, like `328`
contentJSX.Element
Content to render inside the panel.
defaultSizeResizableSize | undefined
Default size of the panel (uncontrolled). - Use string as percent, like `20%` - Use number as px, like `328`
maxResizableSize | undefined1
Maximum size of the panel. - Use string as percent, like `20%` - Use number as px, like `328`
minResizableSize | undefined0
Minimum size of the panel. - Use string as percent, like `20%` - 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.
panelIdstring | undefined
Unique identifier for the panel.
resizableboolean | undefinedtrue
Whether the panel is resizable.
sizeResizableSize | undefined
Current size of the panel (controlled). - Use string as percent, like `20%` - Use number as px, like `328`
styleJSX.CSSProperties | undefined
Additional CSS styles for the panel.