Resizable

Accessible resizable panel groups and layouts with keyboard support.

Horizontal
One
Two
Vertical
Header
Content
With Handle
Sidebar
Content
Nested
One
Two
Three
Controlled
Content
Sidebar 25%, content 75%

About

Resizable is built on @sveltery/resizable, a Svelte package that takes its design from react-resizable-panels by Brian Vaughn. The layout algorithm is a port of that library under the MIT License. The package has no runtime dependencies, and you bind to its state with bind:layout and bind:this instead of calling an imperative API.

Installation

Install the dependencies

pnpm add cn @sveltery/resizable

Copy the component

Copy the following files into src/lib/components/ui/resizable.

src/lib/components/ui/resizable/ResizableHandle.svelte
<script lang="ts">
  import { Separator } from '@sveltery/resizable';
  import { cn } from 'cn';
  import type { ResizableHandleProps } from './types.js';
  let { withHandle = false, class: classProp, ...props }: ResizableHandleProps = $props();
</script>
<Separator
  data-slot="resizable-handle"
  class={cn('relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90', classProp)}
  {...props}
>
  {#if withHandle}
    <div class="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0"></div>
  {/if}
</Separator>
src/lib/components/ui/resizable/ResizablePanel.svelte
<script lang="ts">
  import { Panel } from '@sveltery/resizable';
  import type { PanelSize, SizeInput } from '@sveltery/resizable';
  import type { ResizablePanelProps } from './types.js';
  let { ...props }: ResizablePanelProps = $props();
  let panel: ReturnType<typeof Panel> | undefined;
  export function collapse(): void { panel?.collapse(); }
  export function expand(): void { panel?.expand(); }
  export function resize(size: SizeInput): void { panel?.resize(size); }
  export function getSize(): PanelSize | undefined { return panel?.getSize(); }
  export function isCollapsed(): boolean { return panel?.isCollapsed() ?? false; }
</script>
<Panel bind:this={panel} data-slot="resizable-panel" {...props} />
src/lib/components/ui/resizable/ResizablePanelGroup.svelte
<script lang="ts">
  import { Group } from '@sveltery/resizable';
  import { cn } from 'cn';
  import type { ResizablePanelGroupProps } from './types.js';
  let { layout = $bindable(), class: classProp, ...props }: ResizablePanelGroupProps = $props();
</script>
<Group data-slot="resizable-panel-group" bind:layout class={cn('flex h-full w-full aria-[orientation=vertical]:flex-col', classProp)} {...props} />
src/lib/components/ui/resizable/types.ts
import type { GroupProps, PanelProps, SeparatorProps } from '@sveltery/resizable';

export type ResizablePanelGroupProps = GroupProps;
export type ResizablePanelProps = PanelProps;
export type ResizableHandleProps = Omit<SeparatorProps, 'children'> & { withHandle?: boolean };
export type { Layout, LayoutChangeDetails, Orientation, PanelSize, SizeInput } from '@sveltery/resizable';
src/lib/components/ui/resizable/index.ts
export { default as ResizablePanelGroup } from './ResizablePanelGroup.svelte';
export { default as ResizablePanel } from './ResizablePanel.svelte';
export { default as ResizableHandle } from './ResizableHandle.svelte';
export type { ResizablePanelGroupProps, ResizablePanelProps, ResizableHandleProps } from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '$lib/components/ui/resizable';
</script>
<ResizablePanelGroup orientation="horizontal" class="min-h-[200px] max-w-md rounded-lg border">
  <ResizablePanel defaultSize="50%">One</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel defaultSize="50%">Two</ResizablePanel>
</ResizablePanelGroup>

A group is as tall and wide as its parent, so give it a size, or a min-h, to see it.

Composition

Use the following composition to build a Resizable:

ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanel

A handle sits between two panels and resizes the panel before it against the panel after it. Panel groups nest.

Sizes

A size is a number of pixels or a string with a unit. "25%" and "25" are percent of the group. "200px", "2rem", "1em", "50vh" and "50vw" are converted to percent from the measured group, so they follow the window. defaultSize, minSize, maxSize and collapsedSize all take a size.

<ResizablePanelGroup>
  <ResizablePanel defaultSize="30%" minSize="200px" maxSize="50%">Sidebar</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel>Content</ResizablePanel>
</ResizablePanelGroup>

A panel without a defaultSize takes an equal share of what the other panels leave. The sizes of a group add up to 100 percent, and the group scales them when they do not.

Vertical

Use orientation="vertical" to stack the panels.

<ResizablePanelGroup orientation="vertical" class="min-h-[200px] max-w-md rounded-lg border">
  <ResizablePanel defaultSize="25%">Header</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel defaultSize="75%">Content</ResizablePanel>
</ResizablePanelGroup>

Handle

Use the withHandle prop on ResizableHandle to show a visible grip.

<ResizablePanelGroup orientation="horizontal" class="min-h-[200px] max-w-md rounded-lg border">
  <ResizablePanel defaultSize="25%">Sidebar</ResizablePanel>
  <ResizableHandle withHandle />
  <ResizablePanel defaultSize="75%">Content</ResizablePanel>
</ResizablePanelGroup>

Controlled

Bind layout to read the sizes and to set them. The layout maps each panel id to its percent of the group, so give the panels an id to name them. A layout you assign is validated against the panel limits, and layout then holds the result.

<script lang="ts">
  let layout = $state<Record<string, number>>({ sidebar: 25, content: 75 });
</script>

<ResizablePanelGroup bind:layout>
  <ResizablePanel id="sidebar" defaultSize="25%">Sidebar</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel id="content" defaultSize="75%">Content</ResizablePanel>
</ResizablePanelGroup>

<button onclick={() => (layout = { sidebar: 50, content: 50 })}>Half</button>

onLayoutChange runs for every step of a drag. onLayoutChanged runs once the change settles, which is when a drag ends, and it reports whether the user caused it.

<ResizablePanelGroup
  onLayoutChanged={(layout, { isUserInteraction }) => {
    if (isUserInteraction) save(layout);
  }}
>
  …
</ResizablePanelGroup>

Persistence

savedLayout keeps a layout in localStorage and restores it on the next visit. Spread its result on the group. Pass storage to use another store, such as sessionStorage. A missing or blocked storage leaves the group on its default sizes.

<script lang="ts">
  import { savedLayout } from '@sveltery/resizable';

  const persisted = savedLayout({ id: 'editor' });
</script>

<ResizablePanelGroup {...persisted}>
  <ResizablePanel id="files" defaultSize="30%">Files</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel id="code" defaultSize="70%">Code</ResizablePanel>
</ResizablePanelGroup>

The server renders the default sizes, and the browser applies the saved ones when the page loads.

Collapsible panels

A collapsible panel snaps to collapsedSize when it is dragged below half way between collapsedSize and minSize, and snaps back to minSize when it is dragged above that. Set collapsedThreshold to choose that distance instead: with collapsedSize="5%", collapsedThreshold="5%" and minSize="25%", the panel collapses below 20% and expands above 10%. Press Enter on the handle to collapse the panel before it, and press Enter again to restore its last size. data-collapsed marks a collapsed panel for styling.

<ResizablePanelGroup>
  <ResizablePanel defaultSize="30%" minSize="20%" collapsible>Sidebar</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel>Content</ResizablePanel>
</ResizablePanelGroup>

Bind the panel with bind:this to collapse, expand or resize it from your own controls.

<script lang="ts">
  let sidebar = $state<ResizablePanel>();
</script>

<button onclick={() => sidebar?.collapse()}>Collapse</button>
<button onclick={() => sidebar?.expand()}>Expand</button>
<button onclick={() => sidebar?.resize('40%')}>40%</button>

<ResizablePanelGroup>
  <ResizablePanel bind:this={sidebar} defaultSize="30%" minSize="20%" collapsible>Sidebar</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel>Content</ResizablePanel>
</ResizablePanelGroup>

Keyboard

A handle is a focusable window splitter. It follows the WAI-ARIA pattern and reports the size of the panel before it in aria-valuenow, aria-valuemin and aria-valuemax.

KeyAction
Arrow Left, Arrow RightMove a vertical handle by 5 percent in a horizontal group
Arrow Up, Arrow DownMove a horizontal handle by 5 percent in a vertical group
Home, EndMove the handle as far as the panels allow
EnterCollapse or expand the panel before the handle, when it is collapsible
F6, Shift+F6Move focus to the next or previous handle in the group

Double-click a handle to reset the first panel beside it that has a defaultSize to that size. Set disableDoubleClick on the handle to turn this off.

RTL

A horizontal group follows the text direction. In a right-to-left page the first panel sits on the right, a drag moves the handle with the pointer, and Arrow Left and Arrow Right swap. Set dir on the group or one of its ancestors.

<div dir="rtl">
  <ResizablePanelGroup orientation="horizontal">
    <ResizablePanel defaultSize="50%">One</ResizablePanel>
    <ResizableHandle />
    <ResizablePanel defaultSize="50%">Two</ResizablePanel>
  </ResizablePanelGroup>
</div>

API reference

ResizablePanelGroup

PropTypeDefaultDescription
orientation'horizontal' \| 'vertical''horizontal'The direction the panels sit in
layoutRecord<string, number>Bindable. Percent of the group for each panel id
defaultLayoutRecord<string, number>The layout to start from. Panels it does not name use their defaultSize
onLayoutChange(layout) => voidRuns for every change, including each step of a drag
onLayoutChanged(layout, { isUserInteraction }) => voidRuns once a change settles
disabledbooleanfalseTurns off resizing for every handle
classstringAdded to the group

ResizablePanel

PropTypeDefaultDescription
idstringgeneratedNames the panel in the layout
defaultSizenumber \| stringThe starting size. A number is pixels
minSizenumber \| string0The smallest size
maxSizenumber \| string100%The largest size
collapsiblebooleanfalseLets the panel collapse to collapsedSize
collapsedSizenumber \| string0The size when collapsed
collapsedThresholdnumber \| stringhalf wayHow far past minSize the panel moves before it collapses, and past collapsedSize before it expands
onResize(size, id, previous) => voidRuns when the panel size changes. A size is { asPercentage, inPixels }
classstringAdded to the panel

A panel bound with bind:this has these methods.

MethodDescription
collapse()Collapses a collapsible panel
expand()Restores a collapsed panel to its last size, or to minSize
resize(size)Resizes the panel to a size
getSize()Returns { asPercentage, inPixels }
isCollapsed()Returns whether the panel is collapsed

ResizableHandle

PropTypeDefaultDescription
withHandlebooleanfalseShows a grip in the handle
disabledbooleanfalseTurns off resizing for this handle
disableDoubleClickbooleanfalseStops a double click from resetting the panel beside the handle to its default size
classstringAdded to the handle

ResizablePanelGroup, ResizablePanel and ResizableHandle pass every other attribute to their div.

savedLayout

OptionTypeDefaultDescription
idstringNames the saved layout. The key is sveltery-resizable:<id>
storage{ getItem, setItem }localStorageWhere to keep the layout

It returns { id, defaultLayout, onLayoutChanged } to spread on a ResizablePanelGroup.