Resizable
Split a layout into panes the user can drag, horizontally or vertically, nested as deep as the design needs. Sizes are percentages, so a layout keeps its proportions when the window changes, and a group can remember where the user left it.
Playground
Experiment with different props in real-time.
sizes Basic Usage
A group is a list of panes and nothing else: a handle appears between each pair. The root
fills its parent, so the parent is where the height comes from. A pane without defaultSize takes whatever the others leave.
<script lang="ts">
import { Resizable } from 'sv5ui';
</script>
<!-- The group fills its parent, so give the parent a height. -->
<div class="h-64 overflow-hidden rounded-xl border border-outline-variant">
<Resizable
panes={[
{ id: 'list', defaultSize: 35, content: list },
{ id: 'detail', content: detail }
]}
/>
</div>
{#snippet list()}
<div class="h-full p-4">Conversations</div>
{/snippet}
{#snippet detail()}
<div class="h-full p-4">Message</div>
{/snippet}
<!-- A pane without defaultSize shares whatever the others leave, so the
detail pane above starts at 65. -->Direction
direction decides the axis, and the keyboard follows it: Right and Left drive a horizontal group, Down and
Up a vertical one.
<!-- horizontal (default): panes sit side by side, handles are vertical -->
<Resizable direction="horizontal" {panes} />
<!-- vertical: panes stack, handles are horizontal. The arrow keys follow the
axis, so Up and Down drive a vertical group. -->
<Resizable direction="vertical" {panes} />Nested Groups
A pane's content is a snippet, so a group nests inside a group with no special API. This is the usual editor shell: an explorer, a stack of editor and terminal, and an inspector. Each group keeps its own percentages, so dragging the outer handles leaves the inner split alone.
Explorer
src/app.css
src/routes/+page.svelte
src/lib/index.ts
<script lang="ts">
let count = $state(0);
</script>
$ bun run dev
ready in 412 ms
Inspector
width 320
height 180
<!-- A pane's content is just a snippet, so a group can hold another group.
This is the usual editor shell: a sidebar, then a stack of editor and
terminal, then an inspector. -->
<Resizable
panes={[
{ id: 'files', defaultSize: 22, minSize: '160px', collapsible: true, content: files },
{ id: 'center', minSize: 30, content: center },
{ id: 'inspector', defaultSize: 22, collapsible: true, content: inspector }
]}
/>
{#snippet center()}
<Resizable
direction="vertical"
panes={[
{ id: 'editor', defaultSize: 70, minSize: 20, content: editor },
{ id: 'terminal', minSize: 15, collapsible: true, content: terminal }
]}
/>
{/snippet}
<!-- Each group keeps its own percentages, so the inner layout survives a
drag of the outer one. -->Minimum and Maximum Sizes
Limits take a percentage or a pixel string. A percentage is measured against the group, which keeps its meaning as the window changes shape. A pixel string is resolved against the measured group instead, which is what content with a real minimum width wants: a 320px pane stays readable whether the group is 700px or 1600px wide.
<Resizable
panes={[
{
id: 'sidebar',
defaultSize: 25,
// A percentage is measured against the group.
minSize: 15,
maxSize: 40,
content: sidebar
},
{
id: 'main',
// A pixel string is resolved against the measured group instead, which
// is what you want for content with a real minimum width. It keeps
// meaning the same thing when the window is resized.
minSize: '320px',
content: main
}
]}
/>Chained Resizing
Drag the first handle all the way to the right. The middle pane shrinks to its minimum, and rather than stopping there the drag carries on into the third pane. Chaining is what stops a multi-pane layout from feeling jammed, and it is the reason a drag can change three numbers at once.
<script lang="ts">
import { Resizable } from 'sv5ui';
let sizes = $state<number[]>([]);
</script>
<!-- Drag the first handle all the way right. The middle pane shrinks to its
minimum, and instead of stopping there the drag carries on into the
third pane. That is chained resizing, and it is what keeps a multi-pane
layout from feeling stuck. -->
<Resizable
bind:sizes
panes={[
{ id: 'a', defaultSize: 25, minSize: 15, content: a },
{ id: 'b', defaultSize: 40, minSize: 15, content: b },
{ id: 'c', defaultSize: 35, minSize: 15, content: c }
]}
/>
<p>{sizes.map((n) => n.toFixed(1)).join(' / ')}</p>Collapsible Panes
collapsible lets a pane snap shut when it is dragged past its minimum and spring back when it is dragged out
again. The snap only happens when the drag has enough distance left to swallow the whole pane,
so a small overshoot leaves it open. collapsedSize keeps a sliver on screen instead of closing fully, which is how an icon rail is built.
<!-- collapsible lets a pane snap shut when it is dragged past its minimum,
and spring back when it is dragged out again. It only collapses when the
drag has enough left over to swallow the whole pane, so a small
overshoot does not close it by accident. -->
<Resizable
onCollapse={(id, collapsed) => console.log(id, collapsed)}
panes={[
{
id: 'sidebar',
defaultSize: 25,
minSize: 15,
collapsible: true,
// collapsedSize keeps a sliver on screen instead of closing fully,
// which is how an icon rail is built.
collapsedSize: 6,
content: sidebar
},
{ id: 'main', content: main }
]}
/>
<!-- Enter on a focused handle collapses or restores the pane before it. -->Pinned Panes
resizable: false pins a pane at its size. The handles beside it go inert for both pointer and keyboard, and a
chained drag starting elsewhere in the group never takes its space either. An icon rail next to
a resizable list is the usual case.
Thread 1
A long enough preview line to need clipping
Thread 2
A long enough preview line to need clipping
Thread 3
A long enough preview line to need clipping
Thread 4
A long enough preview line to need clipping
Thread 5
A long enough preview line to need clipping
Thread 6
A long enough preview line to need clipping
Thread 7
A long enough preview line to need clipping
Thread 8
A long enough preview line to need clipping
Thread 9
A long enough preview line to need clipping
Thread 10
A long enough preview line to need clipping
Thread 11
A long enough preview line to need clipping
Thread 12
A long enough preview line to need clipping
Thread 13
A long enough preview line to need clipping
Thread 14
A long enough preview line to need clipping
Re: layout persistence
The pane content is a snippet, so anything can live in here, including another group.
<!-- resizable: false pins a pane. The handles beside it stop responding to
the pointer and the keyboard, and a chained drag starting anywhere else
in the group never takes space from it either. -->
<Resizable
panes={[
{ id: 'rail', defaultSize: 12, resizable: false, content: rail },
{ id: 'list', defaultSize: 30, minSize: 15, content: list },
{ id: 'reader', minSize: 20, content: reader }
]}
/>The list above scrolls inside a ScrollArea, because a pane clips its content rather than growing with it.
Controlled Sizes
sizes binds
both ways: it reports the layout as percentages, and assigning an array applies it. The values
are normalized to add up to 100, so a rough split is enough.
30.0 / 70.0<script lang="ts">
import { Resizable, Button } from 'sv5ui';
// sizes is bindable in both directions: it reports the current layout, and
// assigning an array applies it. The values are percentages and are
// normalized to add up to 100, so [1, 1] and [50, 50] mean the same thing.
let sizes = $state<number[]>([30, 70]);
</script>
<Resizable bind:sizes {panes} />
<Button label="Even" onclick={() => (sizes = [50, 50])} />
<Button label="Reading" onclick={() => (sizes = [20, 80])} />Persisted Layouts
storageKey writes the layout to localStorage after every settled change, together with the list of pane ids. Drag the handles below, reload
this page, and the split is where you left it.
<!-- storageKey writes the layout to localStorage on every settled change,
alongside the list of pane ids. -->
<Resizable storageKey="workspace-layout" {panes} />
<!-- What is stored looks like this:
{ "ids": ["sidebar", "main"], "sizes": [24.5, 75.5] }
On mount the stored layout is only restored when its ids still match the
current panes. Add, remove or rename a pane and the old entry is ignored
and the defaults apply, so yesterday's proportions are never stretched
over a layout they were not measured for.
Every storage access is wrapped in try/catch, so private mode or a
browser that blocks site data degrades to a non-persistent group rather
than an exception. -->Imperative Api
bind:api drives the group from anywhere: a menu item that hides a sidebar, a keyboard shortcut, a preset
layout. expand() restores
the size a pane had before it collapsed, falling back to its minimum when there is no earlier
size.
sizes collapsed none<script lang="ts">
import { Resizable, Button, type ResizableApi } from 'sv5ui';
let api = $state<ResizableApi>();
</script>
<Resizable bind:api {panes} />
<Button label="Hide sidebar" onclick={() => api?.collapse('sidebar')} />
<Button label="Show sidebar" onclick={() => api?.expand('sidebar')} />
<Button label="Toggle" onclick={() => api?.toggle('sidebar')} />
<!-- resize sets one pane exactly; the neighbours absorb the difference -->
<Button label="Sidebar to 30%" onclick={() => api?.resize('sidebar', 30)} />
<!-- setSizes replaces the whole layout, reset returns to the defaultSize
values declared on the panes -->
<Button label="Thirds" onclick={() => api?.setSizes([33.3, 33.3, 33.4])} />
<Button label="Reset" onclick={() => api?.reset()} />
<p>{api?.sizes.join(', ')} · collapsed: {api?.collapsed.join(', ')}</p>
<!-- expand() restores the size the pane had before it collapsed, and falls
back to its minimum when there is no earlier size to return to. -->Custom Handle
The handle snippet replaces the grip drawn inside a handle, not the handle itself, so the hit area, the focus
ring and the separator semantics stay exactly as they were. The context tells you which handle
it is, whether it is being dragged, and along which axis.
<script lang="ts">
import { Resizable, Icon } from 'sv5ui';
</script>
<Resizable {panes}>
{#snippet handle({ index, active, direction })}
<Icon
name={direction === 'horizontal' ? 'lucide:grip-vertical' : 'lucide:grip-horizontal'}
class={active ? 'size-4 text-primary' : 'size-4 text-on-surface/40'}
/>
{/snippet}
</Resizable>
<!-- The snippet replaces the grip inside the handle, not the handle itself,
so the hit area, the focus ring and the separator semantics stay. -->Events
onSizesChange fires for every layout change whatever caused it, which makes it the one to bind app state to. onResizeStart and onResizeEnd bracket a pointer drag, so saving a layout by hand belongs in the second one rather than on every
frame.
Drag a handle, or press Enter on one
<Resizable
{panes}
onSizesChange={(sizes) => (layout = sizes)}
onResizeStart={(index) => console.log('handle', index)}
onResizeEnd={(sizes) => save(sizes)}
onCollapse={(id, collapsed) => console.log(id, collapsed)}
/>
<!-- onSizesChange fires for every layout change, whichever input caused it:
a drag, an arrow key, an api call or an assignment to bind:sizes.
onResizeStart and onResizeEnd bracket a pointer drag, which makes
onResizeEnd the right place to persist a layout by hand. -->Scrolling Inside a Pane
A pane clips its content, so a list longer than the pane needs a scroller of its own. ScrollArea is the one that keeps the scrollbar looking like the rest of the app, and it needs a bounded height, which a pane already provides.
Thread 1
A long enough preview line to need clipping
Thread 2
A long enough preview line to need clipping
Thread 3
A long enough preview line to need clipping
Thread 4
A long enough preview line to need clipping
Thread 5
A long enough preview line to need clipping
Thread 6
A long enough preview line to need clipping
Thread 7
A long enough preview line to need clipping
Thread 8
A long enough preview line to need clipping
Thread 9
A long enough preview line to need clipping
Thread 10
A long enough preview line to need clipping
Thread 11
A long enough preview line to need clipping
Thread 12
A long enough preview line to need clipping
Thread 13
A long enough preview line to need clipping
Thread 14
A long enough preview line to need clipping
Re: layout persistence
The pane content is a snippet, so anything can live in here, including another group.
<script lang="ts">
import { Resizable, ScrollArea } from 'sv5ui';
</script>
<!-- A pane clips its content: the slot is overflow-hidden so a wide child
cannot push the layout around. Long content therefore needs its own
scroller, and ScrollArea is the one that keeps the styling consistent. -->
<Resizable panes={[{ id: 'list', defaultSize: 30, content: list }, { id: 'body', content: body }]} />
{#snippet list()}
<ScrollArea class="h-full">
{#each conversations as item (item.id)}
<Row {item} />
{/each}
</ScrollArea>
{/snippet}Appearance
color tints the handle on hover and while dragging, size sets how thick it is, and disabled freezes
the layout for both pointer and keyboard.
<!-- color tints the handle on hover and while dragging -->
<Resizable color="secondary" {panes} />
<!-- size sets the thickness of the handle and its grip -->
<Resizable size="lg" {panes} />
<!-- disabled freezes the layout: no dragging, no keyboard -->
<Resizable disabled {panes} />
<!-- Four slots. Pane classes can also be set per pane through pane.class -->
<Resizable
{panes}
ui={{
root: 'rounded-xl border border-outline-variant',
handle: 'bg-transparent hover:bg-primary/20',
grip: 'bg-primary'
}}
/>Global Configuration
Set the defaults once for every group in the app.
See Theming for the full configuration surface.
import { defineConfig } from 'sv5ui';
defineConfig({
resizable: {
defaultVariants: { color: 'secondary', size: 'sm' },
slots: { handle: 'bg-outline-variant' }
}
});Keyboard Navigation
Every handle is a separator that takes focus and reports its value to assistive technology.
| Key | Action |
|---|---|
Arrow along the axis | Right and Left in a horizontal group, Down and Up in a vertical one: moves the handle by keyboardStep percent |
Shift + Arrow | The same move at double the step |
Home | Shrinks the pane before the handle to its minimum size |
End | Grows it to its maximum size |
Enter | Collapses or restores the pane before the handle, when that pane is collapsible |
Tab | Each handle is a role="separator" that takes focus, and reports its value through aria-valuenow |
Pane
Each entry of the panes array.
| Field | Type | Default |
|---|---|---|
id | string | required |
defaultSize | number | - |
minSize | number | string | - |
maxSize | number | string | - |
collapsible | boolean | false |
collapsedSize | number | 0 |
resizable | boolean | true |
class | string | - |
content | Snippet<[ResizablePaneContext]> | - |
Api
Reach these through bind:api.
| Member | Type |
|---|---|
collapse(id) | (id: string) => void |
expand(id) | (id: string) => void |
toggle(id) | (id: string) => void |
resize(id, size) | (id: string, size: number) => void |
setSizes(sizes) | (sizes: number[]) => void |
reset() | () => void |
sizes | readonly number[] |
collapsed | readonly string[] |
Events
Every callback is a plain prop.
| Prop | Signature |
|---|---|
onSizesChange | (sizes: number[]) => void |
onResizeStart | (index: number) => void |
onResizeEnd | (sizes: number[]) => void |
onCollapse | (id: string, collapsed: boolean) => void |
UI Slots
Use the ui prop to override classes.
| Slot | Description |
|---|---|
root | The group. Fills its parent and flips axis with direction |
pane | One pane. overflow-hidden, so long content needs its own scroller |
handle | The draggable separator between two panes |
grip | The small bar drawn inside a handle |
Props
Every other prop is forwarded to the root element.
| Prop | Type | Default |
|---|---|---|
panes | ResizablePane[] | required |
direction | 'horizontal' | 'vertical' | 'horizontal' |
sizes | number[] | - |
storageKey | string | - |
keyboardStep | number | 5 |
disabled | boolean | false |
color | primary | secondary | tertiary | success | warning | error | info | surface | 'primary' |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
api | ResizableApi | - |
labels | { handle?: string } | 'Resize panes' |
handle | Snippet<[ResizableHandleContext]> | - |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |