Skip to content
omaris

Containment

Resizable

A split view whose panels you drag, with a layout it remembers.

import { ResizablePanelGroup } from 'omaris'
Learn

Examples

Basic

A sidebar and its content. Drag the handle, or focus it and use the arrow keys; autoSaveId remembers the layout in localStorage, so a pane someone dragged is still there tomorrow.

Sidebar

Drag the handle

<script lang="ts">	import { ResizablePanelGroup, ResizablePanel, ResizableHandle, Text } from 'omaris';</script>​<div class="h-64 w-full overflow-hidden rounded-shape-lg border border-border">	<ResizablePanelGroup autoSaveId="docs-resizable-basic">		<ResizablePanel defaultSize={30} min={18}>			<div class="flex h-full items-center justify-center bg-surface-container-low">				<Text variant="body-sm" tone="muted">Sidebar</Text>			</div>		</ResizablePanel>		<ResizableHandle withHandle label="Resize sidebar" />		<ResizablePanel>			<div class="flex h-full items-center justify-center">				<Text variant="body-sm" tone="muted">Drag the handle</Text>			</div>		</ResizablePanel>	</ResizablePanelGroup></div>

Snapping and pixel limits

snapPoints pull the handle onto a share as it comes near — the line pulses as it lands — and min="200px" is a floor in pixels that holds however the group is resized. The readout is onLayout.

0%

snaps at 25 · 50 · 75, never under 200px

0%

takes whatever is left

<script lang="ts">	import { ResizablePanelGroup, ResizablePanel, ResizableHandle, Text } from 'omaris';​	let sizes = $state<number[]>([]);</script>​<div class="flex w-full flex-col gap-3">	<div class="h-56 w-full overflow-hidden rounded-shape-lg border border-border">		<ResizablePanelGroup bind:sizes>			<ResizablePanel defaultSize={40} min="200px" snapPoints={[25, 50, 75]}>				<div					class="flex h-full flex-col items-center justify-center gap-1 bg-surface-container-low"				>					<Text variant="title-md" tabular>{Math.round(sizes[0] ?? 0)}%</Text>					<Text variant="label-sm" tone="muted">snaps at 25 · 50 · 75, never under 200px</Text>				</div>			</ResizablePanel>			<ResizableHandle withHandle />			<ResizablePanel min={15}>				<div class="flex h-full flex-col items-center justify-center gap-1">					<Text variant="title-md" tabular>{Math.round(sizes[1] ?? 0)}%</Text>					<Text variant="label-sm" tone="muted">takes whatever is left</Text>				</div>			</ResizablePanel>		</ResizablePanelGroup>	</div>	<Text variant="body-sm" tone="muted" tabular>		{sizes.map((size) => `${size.toFixed(1)}%`).join(' · ')}	</Text></div>

Collapsible sidebar

bind:collapsed opens and shuts a collapsible panel from a toolbar button, and stays true to a drag that shuts it by hand. The group's reset() puts every panel back at its default.

Inbox

Folders

Messages

<script lang="ts">	import {		Button,		IconButton,		ResizablePanelGroup,		ResizablePanel,		ResizableHandle,		Text	} from 'omaris';​	let group = $state<ResizablePanelGroup>();	let collapsed = $state(false);</script>​<div class="flex w-full flex-col overflow-hidden rounded-shape-lg border border-border">	<div class="flex items-center gap-2 border-b border-border bg-surface-container-low px-2 py-1.5">		<IconButton			aria-label={collapsed ? 'Show sidebar' : 'Hide sidebar'}			aria-pressed={!collapsed}			onclick={() => (collapsed = !collapsed)}		>			<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">				<rect x="3" y="5" width="18" height="14" rx="2" />				<path d="M9 5v14" />			</svg>		</IconButton>		<Text variant="title-sm" class="flex-1">Inbox</Text>		<Button variant="text" size="xs" onclick={() => group?.reset()}>Reset layout</Button>	</div>	<div class="h-56 w-full">		<ResizablePanelGroup bind:this={group}>			<ResizablePanel defaultSize={28} min={18} collapsible bind:collapsed>				<div class="flex h-full items-center justify-center bg-surface-container-low">					<Text variant="body-sm" tone="muted">Folders</Text>				</div>			</ResizablePanel>			<ResizableHandle withHandle label="Resize sidebar" />			<ResizablePanel>				<div class="flex h-full items-center justify-center">					<Text variant="body-sm" tone="muted">						{collapsed ? 'Sidebar hidden — drag the edge to bring it back' : 'Messages'}					</Text>				</div>			</ResizablePanel>		</ResizablePanelGroup>	</div></div>

Nested

Groups nest, so a three-pane inbox is two groups and nothing else.

Folders

Message list

Preview

<script lang="ts">	import { ResizablePanelGroup, ResizablePanel, ResizableHandle, Text } from 'omaris';</script>​<div class="h-72 w-full overflow-hidden rounded-shape-lg border border-border">	<ResizablePanelGroup>		<ResizablePanel defaultSize={28} min={20}>			<div class="flex h-full items-center justify-center bg-surface-container-low">				<Text variant="body-sm" tone="muted">Folders</Text>			</div>		</ResizablePanel>		<ResizableHandle withHandle />		<ResizablePanel>			<ResizablePanelGroup direction="vertical">				<ResizablePanel defaultSize={45}>					<div class="flex h-full items-center justify-center">						<Text variant="body-sm" tone="muted">Message list</Text>					</div>				</ResizablePanel>				<ResizableHandle withHandle />				<ResizablePanel>					<div class="flex h-full items-center justify-center bg-surface-container-low">						<Text variant="body-sm" tone="muted">Preview</Text>					</div>				</ResizablePanel>			</ResizablePanelGroup>		</ResizablePanel>	</ResizablePanelGroup></div>

Vertical and persisted

direction="vertical" stacks the panels; the handle turns with them and the up and down arrows drive it. autoSaveId keeps this split across reloads, and onLayout reports every settled layout.

Editor

Terminal

onLayout → …

<script lang="ts">	import { ResizablePanelGroup, ResizablePanel, ResizableHandle, Text } from 'omaris';​	let sizes = $state<number[]>([]);</script>​<div class="flex w-full flex-col gap-3">	<div class="h-72 w-full overflow-hidden rounded-shape-lg border border-border">		<ResizablePanelGroup			direction="vertical"			autoSaveId="docs-resizable-vertical"			onLayout={(next) => (sizes = next)}		>			<ResizablePanel defaultSize={60} min={20}>				<div class="flex h-full items-center justify-center">					<Text variant="body-sm" tone="muted">Editor</Text>				</div>			</ResizablePanel>			<ResizableHandle withHandle label="Resize terminal" />			<ResizablePanel min={15} collapsible collapsedSize={0}>				<div class="flex h-full items-center justify-center bg-surface-container-low">					<Text variant="body-sm" tone="muted">Terminal</Text>				</div>			</ResizablePanel>		</ResizablePanelGroup>	</div>	<Text variant="body-sm" tone="muted" tabular>		onLayout → {sizes.map((size) => `${size.toFixed(1)}%`).join(' / ') || '…'}	</Text></div>

Overridden

Every part is reachable. hitArea={16} widens the pointer target without touching the line, classes restyles the line and the grip, and a disabled group keeps its layout but nothing in it moves.

Wide hit area

Restyled line and grip

Disabled group

Nothing here moves

<script lang="ts">	import { ResizablePanelGroup, ResizablePanel, ResizableHandle, Text } from 'omaris';</script>​<div class="flex w-full flex-col gap-4">	<div class="h-48 w-full overflow-hidden rounded-shape-lg border border-border">		<ResizablePanelGroup>			<ResizablePanel defaultSize={35} min={20}>				<div class="flex h-full items-center justify-center bg-surface-container-low">					<Text variant="body-sm" tone="muted">Wide hit area</Text>				</div>			</ResizablePanel>			<ResizableHandle				withHandle				hitArea={16}				classes={{					root: 'bg-surface-container after:bg-tertiary/40 hover:after:bg-tertiary data-dragging:after:bg-tertiary',					grip: 'h-12 w-2 bg-tertiary/60 group-hover/handle:bg-tertiary group-data-dragging/handle:bg-tertiary'				}}			/>			<ResizablePanel>				<div class="flex h-full items-center justify-center">					<Text variant="body-sm" tone="muted">Restyled line and grip</Text>				</div>			</ResizablePanel>		</ResizablePanelGroup>	</div>​	<div class="h-32 w-full overflow-hidden rounded-shape-lg border border-border">		<ResizablePanelGroup disabled sizes={[40, 60]}>			<ResizablePanel>				<div class="flex h-full items-center justify-center bg-surface-container-low">					<Text variant="body-sm" tone="muted">Disabled group</Text>				</div>			</ResizablePanel>			<ResizableHandle withHandle />			<ResizablePanel>				<div class="flex h-full items-center justify-center">					<Text variant="body-sm" tone="muted">Nothing here moves</Text>				</div>			</ResizablePanel>		</ResizablePanelGroup>	</div></div>

When to use it

Use it for

  • A dashboard split: a sidebar and its content, or a three-pane inbox (folders, messages, preview) as two nested groups.
  • An editor over a terminal, or a chart over its data: direction="vertical".
  • A sidebar the person closes and reopens from a toolbar button: collapsible with bind:collapsed, and reset() on the group behind "Reset layout".
  • A layout that survives a reload: autoSaveId stores the sizes in localStorage.

Not for

  • The frame of the app, what scrolls and where the app bar sits → App Shell. Put a ResizablePanelGroup inside AppShellMain when a pane in the content needs dragging.
  • Two columns nobody will resize → a plain grid-cols-[16rem_1fr].
  • A panel that comes and goes → Sheet.
  • Content split by topic rather than by space → Tabs.
  • A phone. Stack the panes below md, or put the second one in a Sheet.

Do

  • Give the group a height. It fills its parent, which needs h-… or a flex column.
  • Set withHandle and a label ("Resize sidebar") on every handle people are meant to find. The line alone is 1px.
  • Use min="240px" for a panel with fixed-width content, like a nav rail or a form. Use a percentage for one that scales with the window.
  • Put min-w-0 and overflow-auto inside a panel, so long content scrolls in the pane instead of forcing it wider.

Don't

  • Write w-… on a ResizablePanel. Sizes are shares the group owns.
  • Reuse an autoSaveId for two different groups. They trade layouts.
  • Make a panel collapsible with the drag as the only way back. Pair it with a button through bind:collapsed.
  • Shrink hitArea to make the divider thinner. The line is 1px already; the hit area is what makes it grabbable.

Quick reference

direction ResizablePanelGroup
  • horizontal (default)
  • vertical
direction ResizableHandle
  • horizontal (default)
  • vertical

API

ResizablePanelGroup

Resizable panels — the dashboard split view.

The group owns the layout, in percentages; the panels and handles inside it register themselves and read their share back out, so nothing has to be wired up by hand:

Space taken from one side is given to the other nearest-first, so a drag past a neighbour's minimum keeps going into the one after it instead of stopping dead. collapsible panels snap shut — and back open — as the handle crosses the midpoint between their collapsed and minimum sizes, and a panel with snapPoints pulls the handle onto them as it comes near, which is what makes a sidebar feel magnetic rather than fiddly.

Limits can be pixels as well as percentages: min="240px" is resolved against the group's own extent and re-resolved whenever that changes, so a sidebar keeps its floor when the window shrinks.

Panels and handles are paired by where they are in the DOM, not by the order they mounted in, so a panel behind an {#if} can come and go and every handle still knows which two panels it splits. Sizes are plain numbers, so autoSaveId can put them in localStorage and hand them back on the next visit.

import { ResizablePanelGroup } from 'omaris'
<ResizablePanelGroup autoSaveId="inbox" bind:this={group}>  <ResizablePanel defaultSize={28} min="220px" collapsible bind:collapsed>…</ResizablePanel>  <ResizableHandle withHandle />  <ResizablePanel snapPoints={[50]}>…</ResizablePanel></ResizablePanelGroup>

Props

direction

Defaults to 'horizontal'

ResizableDirection

Axis the panels are laid out along.

horizontal
vertical
sizes bindable

Defaults to []

number[]

Panel sizes as percentages, in order. Bindable.

autoSaveId
string

Persist the layout under this key in localStorage.

disabled

Defaults to false

boolean

Freeze the layout: no handle can be dragged, keyed or double-clicked.

step

Defaults to 5

number

Percentage points one arrow press moves a handle. A handle's own keyboardStep wins.

onLayout
(sizes: number[]) => void

Fires whenever the layout settles on new sizes.

onDragStart
() => void

A handle has been grabbed.

onDragEnd
(sizes: number[]) => void

The handle has been let go, with the sizes it left behind.

class
string
children
Snippet

ResizableHandle

The divider between two panels — and the thing you drag.

The hit area is deliberately wider than the line it draws: a 1px divider is impossible to grab, so the target is hitArea px of transparent padding with the hairline centred inside it — and at least 24px of it under a coarse pointer, because 8px is a mouse's margin of error, not a finger's. That line thickens and takes the primary colour while it is hovered, focused or dragged, under an MD3 state layer, so the affordance shows up before you press rather than after — and it pulses for a moment each time a drag lands on a snap point, so the magnet is felt as well as seen.

It is a real separator for assistive tech: arrows move it, Home and End throw it to either extreme, and Enter — or a double-click — collapses or restores a collapsible neighbour, or puts both neighbours back at their defaults when neither collapses.

import { ResizableHandle } from 'omaris'

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
grip
A raised pill centred on the divider; primary while the handle is live.

Props

withHandle

Defaults to false

boolean

Draw the grip, so the divider is obviously draggable.

keyboardStep
number

Percentage points one arrow press moves it. Falls back to the group's step.

hitArea

Defaults to 8

number

Width of the pointer target in px. The line it draws stays 1px, and a coarse pointer gets at least 24px however small this is.

disabled

Defaults to false

boolean
label

Defaults to 'Resize'

string

Accessible name — "Resize sidebar".

class
string
classes
ResizableHandleClasses

Per-part Tailwind overrides. class still covers the root.

ResizablePanel

One pane of a ResizablePanelGroup.

It grows in proportion to its share of the layout, so no width or height is ever written onto it — which is what lets a drag stay at 60fps and a collapsed panel disappear cleanly at zero.

Its limits take a percentage or a pixel string: min="240px" is a floor in pixels whatever the group's size, snapPoints={[25, 50, 75]} are the shares a drag magnetises to, and bind:collapsed opens and shuts a collapsible panel from a toolbar button.

import { ResizablePanel } from 'omaris'

Props

defaultSize
ResizableSize

Share of the group to open at — a percentage, or pixels as '320px'.

min

Defaults to 10

ResizableSize

Smallest share it may take — a percentage, or pixels as '240px'.

max

Defaults to 100

ResizableSize

Largest share it may take — a percentage, or pixels as '600px'.

collapsible

Defaults to false

boolean

Let it be squashed shut past min, snapping to collapsedSize.

collapsedSize

Defaults to 0

number

Size a collapsed panel rests at, as a percentage.

collapsed bindable

Defaults to false

boolean

Whether it is shut. Bindable — set it to collapse or expand from outside.

snapPoints
number[]

Percentages a drag pulls onto as the handle comes near.

snapThreshold

Defaults to 3

number

How close, in percentage points, before a snap point pulls.

onResize
(size: number) => void

Fires with the new size whenever this panel is resized.

onCollapse
(collapsed: boolean) => void

Fires when it collapses or opens back up.

class
string
children
Snippet