Containment
Resizable
A split view whose panels you drag, with a layout it remembers.
import { ResizablePanelGroup } from 'omaris' 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 200px0%
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> 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:
collapsiblewithbind:collapsed, andreset()on the group behind "Reset layout". - A layout that survives a reload:
autoSaveIdstores the sizes inlocalStorage.
Not for
- The frame of the app, what scrolls and where the app bar sits → App Shell. Put a
ResizablePanelGroupinsideAppShellMainwhen 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
withHandleand alabel("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-0andoverflow-autoinside a panel, so long content scrolls in the pane instead of forcing it wider.
Don't
- Write
w-…on aResizablePanel. Sizes are shares the group owns. - Reuse an
autoSaveIdfor two different groups. They trade layouts. - Make a panel
collapsiblewith the drag as the only way back. Pair it with a button throughbind:collapsed. - Shrink
hitAreato make the divider thinner. The line is 1px already; the hit area is what makes it grabbable.
Quick reference
direction ResizablePanelGrouphorizontal(default)vertical
direction ResizableHandlehorizontal(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.
horizontalvertical
sizes bindableDefaults 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 bindableDefaults 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