Layout

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.

first 30.0%
second 45.0%
third 25.0%
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.

list 35.0%
detail 65.0%
<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.

left 40.0%
right 60.0%
top 40.0%
bottom 60.0%
<!-- 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.

sidebar (15% to 40%) 25.0%
main (min 320px) 75.0%
<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.

a (min 15) 25.0%
b (min 15) 40.0%
c (min 15) 35.0%
<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.

closes fully 25.0%
main 50.0%
keeps 6% 25.0%
Drag either outer handle inward, or focus a handle and press Enter.
<!-- 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.

nav 30.0%
content 70.0%
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.

saved-left 40.0%
saved-right 60.0%
<!-- 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.

sidebar 25.0%
main 50.0%
aside 25.0%
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.

one 40.0%
two 60.0%
<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.

left 30.0%
right 70.0%
idle

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.

xs, secondary
a 40.0%
b 60.0%
xl, success
a 40.0%
b 60.0%
disabled
a 40.0%
b 60.0%
<!-- 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.

KeyAction
Arrow along the axisRight and Left in a horizontal group, Down and Up in a vertical one: moves the handle by keyboardStep percent
Shift + ArrowThe same move at double the step
HomeShrinks the pane before the handle to its minimum size
EndGrows it to its maximum size
EnterCollapses or restores the pane before the handle, when that pane is collapsible
TabEach handle is a role="separator" that takes focus, and reports its value through aria-valuenow

Pane

Each entry of the panes array.

FieldTypeDefault
idstringrequired
defaultSizenumber-
minSizenumber | string-
maxSizenumber | string-
collapsiblebooleanfalse
collapsedSizenumber0
resizablebooleantrue
classstring-
contentSnippet<[ResizablePaneContext]>-

Api

Reach these through bind:api.

MemberType
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
sizesreadonly number[]
collapsedreadonly string[]

Events

Every callback is a plain prop.

PropSignature
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.

SlotDescription
rootThe group. Fills its parent and flips axis with direction
paneOne pane. overflow-hidden, so long content needs its own scroller
handleThe draggable separator between two panes
gripThe small bar drawn inside a handle

Props

Every other prop is forwarded to the root element.

PropTypeDefault
panesResizablePane[]required
direction'horizontal' | 'vertical''horizontal'
sizesnumber[]-
storageKeystring-
keyboardStepnumber5
disabledbooleanfalse
colorprimary | secondary | tertiary | success | warning | error | info | surface'primary'
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
apiResizableApi-
labels{ handle?: string }'Resize panes'
handleSnippet<[ResizableHandleContext]>-
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-